diff --git a/AGENTS.md b/AGENTS.md index f67ac8f..5e9fb0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,9 +56,12 @@ - Перед каждым `AddBlock` обязательно проверять/актуализировать текущее состояние вершины блокчейна (`last global number/hash`) и использовать его при формировании блока. ## Документация личных сообщений (DM) -- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/README.md`. -- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять `Dev_Docs/Personal_Messages/README.md`. -- Логика личных сообщений в коде должна всегда соответствовать `Dev_Docs/Personal_Messages/README.md`. +- Актуальная документация по логике личных сообщений находится в `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`. +- Точный байтовый формат DM находится в `Dev_Docs/Personal_Messages/Формат_DM_v1.md`. +- При любом изменении кода, связанного с личными сообщениями (формат подписанного DM-блока, типы DM-сообщений, правила доставки/ACK/read-receipt, роутинг по сессиям, UI-логика чатов), обязательно обновлять оба документа: + - `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` + - `Dev_Docs/Personal_Messages/Формат_DM_v1.md` +- Логика личных сообщений в коде должна всегда соответствовать этим документам. - Документ по личным сообщениям обязан поддерживаться в актуальном состоянии. ## Документация API сервера @@ -68,10 +71,11 @@ - Без явного подтверждения пользователя формат серверного API не менять; допускается только приведение документации в соответствие уже существующему коду. - Если добавляется новая операция `op`, нужно обновить общий список операций в `Dev_Docs/API/09_Operations_Index.md` или создать его, если файла ещё нет. -## Известная проблема (временная пометка) -- Мнения по связям пользователя (`known_person`, `shine_confirmed`, `shine_seen`) в UI могут отображаться нестабильно. -- Требуется отдельная доработка и финальная проверка end-to-end: запись мнения в блокчейн → обновление `connections_state` → ответ `GetUserConnectionsGraph` → отображение в UI. -- До фикса считать эту часть функционала незавершённой и обязательно перепроверять вручную после каждого деплоя. +## Документация Figma +- Актуальная документация по переносу экранов SHiNE в Figma и обратному переносу из Figma в код находится в `Dev_Docs/Figma/`. +- Точка входа: `Dev_Docs/Figma/README.md`. +- Подробный рабочий регламент: `Dev_Docs/Figma/TRANSFER_UI_SCREENS.md`. +- Для экранов регистрации, входа и других чувствительных UI-flow по умолчанию переносить экраны в Figma по одному, а не пачкой, если пользователь отдельно не подтвердил иной способ. ## Версионирование - Единый файл версий проекта: `VERSION.properties` (в корне репозитория). @@ -83,14 +87,14 @@ ## Deploy - Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`. -- Production-хост SHiNE: `player@shineup.me` (`185.229.109.118`). -- Основной test-хост SHiNE: `player@193.8.215.70` (`test2.shineup.me`). +- Production-хост SHiNE: `player@shineup.me` (`178.208.64.62`). +- Основной test-хост SHiNE: `player@193.8.215.70` (`t.shineup.me`). - Резервный test-хост SHiNE: `player@93.170.12.154` (`test.shineup.me`). - Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`). - По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке. - Для операций `git push` при необходимости использовать токен из переменной окружения `$GITEA_TOKEN`. - Любые изменения и любой деплой на production `shineup.me` выполнять только после отдельного явного подтверждения пользователя. -- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `test2.shineup.me`. +- Если пользователь пишет просто `задеплой` без уточнения production/test, по умолчанию деплоить на `t.shineup.me`. - Default server deploy: `./gradlew deployServer` или `./gradlew deployServerTest2`. - Default UI deploy: `./gradlew deployUI` или `./gradlew deployUITest2`. - Production server deploy: `./gradlew deployServerProduction`. @@ -134,17 +138,19 @@ - После подтверждения, что фича проверена и работает корректно, соответствующий файл удалять. - В `Dev_Docs/Pending_Features/README.md` вести краткий регламент и поддерживать актуальность. -## Будущие фичи -- Папка для задач, сознательно отложенных на будущее: `Dev_Docs/Future_Features/`. -- Точка входа по планам: `Dev_Docs/Future_Features/README.md`. -- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/`. -- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `Dev_Docs/Future_Features/README.md`, затем при необходимости конкретные файлы из горизонтов. +## Будущие фичи / TODO +- Папка для задач, сознательно отложенных на будущее: `TODO/`. +- Точка входа по планам: `TODO/README.md`. +- Внутри планы разделены по горизонтам: `near/`, `medium/`, `far/` и тематическим подпапкам. +- Если пользователь спрашивает, какие есть планы или что можно продолжить, сначала читать `TODO/README.md`, затем при необходимости конкретные файлы из подпапок. - Файлы из этой папки не считать активными задачами и не начинать реализацию без явной просьбы пользователя. -- Если часть кода временно отключена или закомментирована, в файле будущей фичи подробно описывать: +- Старую папку `Dev_Docs/Future_Features/` считать выведенной из использования и больше не использовать для новых записей. +- Если часть кода временно отключена или закомментирована, либо удалена как временная заглушка, в TODO-файле подробно описывать: - какие файлы и участки отключены; - что осталось в коде как заготовка; - какие документы нужно обновить при возврате; - - с какого сценария продолжать разработку. + - с какого сценария продолжать разработку; + - из какого коммита брать последнюю полную реализацию. ## Коммуникация по новым задачам (обязательно) - При получении нового задания сначала кратко пересказать задачу своими словами. diff --git a/DAO_запуск/README.md b/DAO_запуск/README.md deleted file mode 100644 index 6969f65..0000000 --- a/DAO_запуск/README.md +++ /dev/null @@ -1,255 +0,0 @@ -# DAO_запуск - -Рабочий документ по тому, что ещё нужно сделать для первого запуска DAO-сценария SHiNE. - -Логика документа: - -- `этап1` — то, без чего нельзя считать сценарий первого запуска собранным даже в тестовом виде; -- `этап2` — то, что полезно и, вероятно, потребуется дальше, но это можно делать после старта `этап1` или параллельно без блокировки первого результата. - -Базовая среда первого прохода: - -- сеть `Solana devnet`; -- модель синхронизации: `server-to-server`; -- `Solana + Arweave` используются как якорь и архив; -- DAO понимается как стандартный governance/smart-contract контур, который управляет отдельными программами SHiNE, приносящими деньги. - -## Краткий вывод - -Для первого запуска DAO в тестовом виде текущего списка в целом хватает, но только если понимать запуск как: - -- можно развернуть и проверить базовый DAO-контур; -- можно зарегистрировать пользователей и ключевые сущности; -- можно провести тестовую покупку билета через smart contract; -- можно завести тестовый денежный поток в программы, управляемые DAO; -- можно проверить опорную межсерверную синхронизацию и фиксацию состояния в архивный слой. - -Если же под "запуском" понимать уже полностью устойчивую production-схему с ротацией ключей, восстановлением любого сервера из архива, железными устройствами подписи и полным циклом администрирования, то текущий список нужно будет ещё расширять. - -## Этап1 - -Цель этапа: собрать минимально жизнеспособный DAO-сценарий в `devnet`, который можно пройти руками от регистрации до базовой экономики и проверки архитектуры. - -### 1. Переписать и стабилизировать регистрацию пользователей без Anchor - -Что сделать: - -- довести `shine_users` в чистом Rust/Solana SDK до рабочего и проверенного состояния; -- убедиться, что `shine_login_guard` и связанный сценарий регистрации совместимы с новым ABI; -- проверить создание и чтение `user_pda`; -- проверить update пользовательской записи и связанные экономические параметры; -- синхронизировать сервер, UI и lazy-import с новым форматом и seed'ами. - -Почему это в `этап1`: - -- без стабильной пользовательской регистрации дальше нельзя строить ни DAO-сценарий, ни привязку устройств, ни платёжные сценарии. - -### 2. Проверить полный сценарий регистрации и базовой Solana-интеграции - -Что сделать: - -- руками прогнать регистрацию нового пользователя; -- руками прогнать создание и update server PDA там, где это требуется текущему сценарию; -- убедиться, что сервер читает новые PDA без anchor-зависимостей и без старых discriminator'ов; -- зафиксировать, какие именно части сценария уже подтверждены руками, а какие ещё нет. - -Почему это в `этап1`: - -- сейчас в проекте уже есть признаки перехода на pure Rust, но без ручной проверки это нельзя считать завершённым. - -### 3. Создать стандартный DAO smart contract / governance-контур - -Что сделать: - -- определить и реализовать стандартный DAO-контур, который будет управлять программами SHiNE; -- зафиксировать, какие права сразу передаются DAO, а какие временно остаются на отдельных ключах; -- подготовить тестовую DAO-структуру в `devnet`. - -Минимум для первого запуска: - -- DAO существует как управляемая сущность; -- DAO может владеть или контролировать ключевые права управления денежными программами; -- есть понятный путь, как DAO влияет на доходные программы SHiNE. - -Почему это в `этап1`: - -- без этого "DAO-запуск" будет только запуском отдельных Solana-программ, но не запуском управляемой DAO-системы. - -### 4. Доработать смарт-контракт выплат с третьей очередью - -Что сделать: - -- добавить в `shine_payments` третью очередь, о которой уже принято решение; -- проверить совместимость с текущей моделью тикетов, выплат и DAO-управления; -- убедиться, что логика очередей соответствует ожидаемой экономике проекта. - -Почему это в `этап1`: - -- по текущей постановке это нужно именно для сценария регистрации DAO и дальнейшей экономики. - -### 5. Сделать UI для покупки билетов и просмотра очереди - -Что сделать: - -- добавить UI-сценарий покупки билетов через smart contract; -- показать пользователю, сколько перед ним человек в очереди; -- убедиться, что UI отражает актуальное состояние контрактной логики, а не локальные предположения. - -Почему это в `этап1`: - -- покупка билетов у тебя обозначена как часть DAO-сценария, а не как побочная функция; -- без UI можно тестировать контракт вручную, но нельзя считать сценарий запуска достаточно собранным для нормальной проверки. - -### 6. Реализовать базовую синхронизацию серверов - -Что сделать: - -- сделать обмен состоянием между серверами по модели `server-to-server`; -- определить минимальный набор данных, который обязан синхронизироваться; -- предусмотреть фиксацию синхронизированного состояния в `Arweave`, а `Solana` использовать как якорь и ссылочный слой; -- описать, какой сервер считается источником истины в спорных случаях или как решается конфликт. - -Почему это в `этап1`: - -- без межсерверной синхронизации трудно обосновать архитектуру сети как воспроизводимую и переносимую; -- это напрямую связано с идеей, что любой сможет поднять свой сервер. - -### 7. Подготовить базовый сценарий архивирования и восстановления - -Что сделать: - -- описать и частично реализовать схему: серверы синхронизируются между собой, архив состояния уходит в `Arweave`, ссылка/якорь фиксируется через `Solana`; -- определить минимальный сценарий восстановления блоков или состояния из архивного слоя; -- подтвердить, что новый сервер может получить достаточно данных для старта. - -Почему это в `этап1`: - -- это один из ключевых признаков независимой и воспроизводимой DAO-инфраструктуры. - -## Этап2 - -Цель этапа: усилить безопасность, автономность и удобство системы после того, как минимальный DAO-сценарий уже запустился и проверен в `devnet`. - -### 1. Смена ключей цифровой подписи - -Что сделать: - -- продумать и реализовать смену `root key`, `client key`, `blockchain key`; -- описать ограничения, кто и в каком сценарии может менять каждый тип ключа; -- продумать, как не потерять доступ и как обновлять доверие к новым ключам. - -Почему это в `этап2`: - -- для production это очень важно; -- для первого тестового запуска можно временно использовать фиксированный набор ключей. - -### 2. Полная повторная перепроверка всех сценариев - -Что сделать: - -- повторно прогнать регистрацию, DAO, выплаты, билеты, синхронизацию и архивирование после стабилизации `этап1`; -- оформить итоговый чек-лист ручной проверки; -- отдельно проверить пограничные сценарии и восстановление после ошибок. - -Почему это в `этап2`: - -- это обязательный шаг перед переходом от "собрали" к "доверяем". - -### 3. Устройство на ESP32 как homeserver с ключами - -Что сделать: - -- дописать прошивку, чтобы устройство могло выступать homeserver с ключами; -- дать ему возможность регистрироваться и подключаться к серверу; -- определить, какие операции устройство подписывает и где хранит ключевой материал. - -Почему это в `этап2`: - -- это очень сильное развитие архитектуры, но оно не должно блокировать первый DAO-запуск. - -### 4. Логин и подпись через коробочки / устройства - -Что сделать: - -- реализовать сценарий входа через устройство или хотя бы сценарий подписи сообщений и ключей через устройство; -- определить, как это встраивается в регистрацию DAO и подтверждение действий; -- проверить, можно ли через это безопасно регистрировать DAO или подписывать критичные команды. - -Почему это в `этап2`: - -- это следующий уровень безопасности и UX, но не минимальный блокер первого старта. - -### 5. Создание тестового DAO с использованием устройств подписи - -Что сделать: - -- после готовности устройств собрать тестовый DAO-сценарий уже с аппаратным участием; -- проверить, где устройство достаточно, а где всё ещё нужен обычный кошелёк или управляющий ключ. - -Почему это в `этап2`: - -- это проверка усиленной модели, а не базового старта. - -### 6. Расписание синхронизации серверов - -Что сделать: - -- определить периодичность и правила фоновой синхронизации; -- продумать ручной и автоматический режим; -- решить, как часто публиковать архивные снимки и якоря. - -Почему это в `этап2`: - -- сначала важнее добиться самой работающей синхронизации, а потом уже делать её регулярной и автономной. - -### 7. Полное восстановление блоков из Solana/Arweave - -Что сделать: - -- довести процедуру восстановления до сценария "любой может поднять свой сервер"; -- определить минимальный bootstrap-набор; -- проверить восстановление на чистом окружении. - -Почему это в `этап2`: - -- для концепции сети это критично, но как полноценная задача обычно идёт после появления базового архива и первичной синхронизации. - -## Что блокирует первый запуск сильнее всего - -Если расставить приоритет внутри `этап1`, то самый жёсткий порядок сейчас выглядит так: - -1. pure Rust регистрация пользователей и ручная проверка сценария; -2. DAO/gov-контур и его права управления; -3. доработка выплат с третьей очередью; -4. покупка билетов через smart contract и UI-проверка очереди; -5. межсерверная синхронизация; -6. архивирование в `Arweave` с якорем в `Solana`; -7. минимальное восстановление состояния новым сервером. - -## Что уже частично похоже на готовое - -По текущим документам и следам в проекте уже видно, что: - -- переход `shine_users` и `shine_login_guard` на pure Rust уже начат и в значительной степени сделан; -- архитектура DAO, `shine_users` и `shine_payments` уже описана; -- часть Solana-структуры и PDA-форматов уже формализована; -- тема ESP32 уже отдельно присутствует в проекте как направление. - -Это хорошо, потому что документ получается не "с нуля", а как сборка того, что уже назрело в коде и планах. - -## Вопросы, которые всё ещё стоит уточнить - -1. Какой именно стандарт DAO планируется использовать в первом проходе: готовый governance-стек Solana или собственная минимальная обвязка вокруг управляющих кошельков? -2. Третья очередь в `shine_payments` уже точно определена по смыслу, или пока есть только решение "она нужна", но без финальной экономики? -3. Что именно считается единицей синхронизации между серверами: блоки SHiNE, агрегированные снапшоты, PDA-состояния, или смесь этих вариантов? -4. Нужен ли для `этап1` уже полноценный автоматический recovery нового сервера, или достаточно доказать это в полу-ручном сценарии? -5. Покупка билетов должна в первом проходе работать только через web/UI, или также нужен отдельный сценарий из серверного UI или скриптов? - -## Рекомендуемый следующий практический шаг - -Если идти без распыления, то следующим рабочим фокусом стоит считать: - -1. закрыть ручную проверку pure Rust регистрации; -2. после этого формализовать минимальный DAO-контур; -3. затем переходить к третьей очереди выплат и к UI покупки билетов; -4. после этого делать синхронизацию, архив и восстановление. diff --git a/DEBUG_CONNECTION_TESTING.md b/DEBUG_CONNECTION_TESTING.md deleted file mode 100644 index 22b7b5c..0000000 --- a/DEBUG_CONNECTION_TESTING.md +++ /dev/null @@ -1,92 +0,0 @@ -# DEBUG: тестирование сетевого соединения между двумя клиентами - -Документ описывает временный debug-контур для проверки WebRTC соединения между двумя активными WS-сессиями. - -## 1) Подготовка - - -0. Убедись, что в `application.properties` включен параметр: - `debug.tempApi.enabled=true` -1. Создай файл `.debug-token` в корне проекта на основе `debug-token.example`. -2. В `.debug-token` должна быть одна строка: секретный токен. -3. Перезапусти сервер. - -## 2) API debug - -Базовый заголовок для всех запросов: - -```bash --H "Authorization: Bearer " -``` - -### 2.1 Получить список живых клиентов - -```bash -curl -s \ - -H "Authorization: Bearer " \ - http://localhost:7070/debug/ws/clients | jq -``` - -Ответ содержит `sessionId`, `login`, `ip`, `userAgent`, и клиентскую информацию. - -### 2.2 Запустить debug-соединение между двумя сессиями - -```bash -curl -s -X POST \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{ - "initiatorSessionId": "SESSION_ID_A", - "responderSessionId": "SESSION_ID_B", - "clearDebugLog": false - }' \ - http://localhost:7070/debug/ws/connect | jq -``` - -В ответе придёт `runId`. Его используй для фильтра логов. - -### 2.3 Читать последние N debug-логов - -```bash -curl -s \ - -H "Authorization: Bearer " \ - "http://localhost:7070/debug/ws/logs?limit=200&runId=" | jq -``` - -## 3) Операционный сценарий “Codex + пользователь” - -1. Codex поднимает сервер и сообщает пользователю ссылку на UI. -2. Codex пишет пользователю: **«Запусти двух клиентов и скажи “продолжай”»**. -3. Пользователь запускает два клиента (лучше под разными логинами). -4. Пользователь пишет: **«продолжай»**. -5. Codex: - - вызывает `/debug/ws/clients`, - - выбирает 2 сессии, - - вызывает `/debug/ws/connect`, - - получает `runId`, - - читает `/debug/ws/logs?runId=...` и сообщает прогресс. -6. Если соединение не удалось: - - Codex сообщает ошибки по логам, - - при необходимости просит перезапустить 2 клиента, - - повторяет запуск debug-run. - -## 4) Какие сообщения считать успехом - -- `peer_connection_connected` -- `debug_connection_success` -- `signal_sent_200/210/220` без ошибок - -## 5) Что говорить пользователю в ходе прогона (через «колонку»/чат) - -Рекомендуемые фразы: - -- «Сервер запущен. Запусти двух клиентов и напиши “продолжай”.» -- «Вижу 2 активные сессии, запускаю тест соединения.» -- «Тест запущен, runId=... Сейчас проверяю логи.» -- «Соединение установлено / не установлено. Ниже причины и следующий шаг.» - -## 6) Ограничения - -- Механизм временный, не для production-эксплуатации. -- Доступ к debug API имеет любой, кто знает токен. -- Рекомендуется тестить между разными логинами. diff --git a/DOC/!!! TODO что бы не забыть - для после выходных b/DOC/!!! TODO что бы не забыть - для после выходных deleted file mode 100644 index af96b91..0000000 --- a/DOC/!!! TODO что бы не забыть - для после выходных +++ /dev/null @@ -1,62 +0,0 @@ -0. ПЕРЕДЕЛАТЬ ВСЁ НА НОВЫЙ ФОРМАТ!! - -ВЫНЕСТИ ЭТИ ТРИ ВЕЩИ В ОБЩИЙ ПАРСЕР - * [2] type - тип соощения - * [2] Sиbtype - субтип сообщения - * [2] version - версия формата соощения - -А ОСТАЛЬНОЕ В РЕАЛИЗАЦИЮ - - -ПЕРЕДЕЛАЕМ БД - -1. СДЕЛАЕМ ЛИНИЮ ТОЛЬКО ДЛЯ ТЕХ ТИПОВ КОМУ НАДО (ЛАЙКАМ И ОТВЕТАМ НЕ НАДО) - (НОМЕР СООБЩЕНИЯ В ЛИНИИ ХРАНИТЬ В БЛОКАХ ВРОДЕ И НЕ НАДО ТЕМ БОЛЕЕ ЕГО ПОТОМ ПЕРЕПРОВЕРЯТЬ ВСЁ РАВНО) - А МОЖЕТ И НАДО ТК КАК ПО ОДНОМУ БЛОКУ ( ИЛИ ЧАСТИ БЛОКОВ ПОНЯТЬ КАКАЯ ЭТО ЧАСТЬ ПЕРЕПИСКИ - ВЕДЬ ГЛОБАЛ НОМЕР ВООБЩЕ НЕ ПОКАЗАТЕЛЬ) - - В БД ПОМЕЧАТЬ ЧТО БЛОК ИЗ ЭТОЙ ЛИНИИ (ДЛЯ БЫСТРОГО ПОИСКА) - -А УНИКАЛЬНЫЙ НОМЕР ЛИНИИ ЭТО ПО СУТИ НОМЕР СООБЩЕНИЯ СОЗДАВШЕГО ЛИНИЮ КАНАЛ (НУ И ФОРМАТ СООБЩЕНИЯ НАЧАЛА ЛИНИИ - КАНАЛА) - - - - - - - - - - - - -3. СООТВЕТСТВЕННО удалить НАПИСАТЬ/ПЕРОВЕРИТЬ НОРМАЛЬНЫЙ SubscriptionsDAO - ТК СТАРЫЙ РАБОТАЛ НО НА ДРУГОМ ФОРМАТЕ И ТИПО КРИВО - -и дальше: -ЗДЕЛАТЬ ТРИ ЗАПРОСА: - СПИСОК КАНАЛОВ НА КОГО ПОДПИСАН И ПО СКОЛЬКО СООБЩЕНИЙ И ПОСЛДНИЙ ТЕКСТ - ДОДЕЛАТЬ И СВЯЗ ПОДПИСАН УЖЕ НЕ ТОЛЬКО НА ЧЕЛА НО И НА КАНАЛ. (И ПОЛУЧАЕТСЯ ЕСТЬ ОБЩИЙ КАНАЛЛ ПОСТОВ (НО НЕКОТОРЫЕ ПОСТЫ В НИКУДА- - А НЕКОТОРЫЕ ПОСТЫ ОБЪЯВЛЕНИЕ КАНАЛА - - СПИСОК СООБЩЕНИЙ В КАНАЛЕ - - ОПСИСАНИЕ ОДНОГО СОООБЩЕНИЯ (С ИСТОРИЕЙ ДО НАЧАЛА ВЕТКИ И СО ВСЕМИ ОТВЕТАМИ НА НЕГО) - - (НУ И В БУДУЩЕМ четвёртый ИСТОРИЮ сообщения ПО ЕДИТУ) - - -И ПОМЯТКА - ВСЕГДА СЧИТАЕМ ПО ПОСЛЕДНЕМУ БЛОКЧЕЙНУ ДОСТУПНОМУ ПОЛЬЗОВАТЕЛЮ - ХОТЯ ССЫЛКА ПО НОМЕРУ БЛОКЧЕЙНА КУДА ДОБАВИЛИ - - ЛАЙКИ И ОТВЕТЫ ПИШЕМ НА НОМЕР СООБЩЕНИЯ ЕДИТА - (СЧИТАЕМ ТРИГЕРОМ И НА ОРИГИНАЛЬНЫЙ СУМАРНОЕ И ОТДЕЛЬНО НА НЕГО, И НА КАЖДЫЙ ЕДИТ ОТДЕЛЬНО) - - ОТВЕТЫ ПОКАЗЫВАЕМ ВСЕ ВРАЗ - - - - - - - - diff --git a/DOC/TODO то что пока отложенно на будущее.md b/DOC/TODO то что пока отложенно на будущее.md deleted file mode 100644 index 7eb9828..0000000 --- a/DOC/TODO то что пока отложенно на будущее.md +++ /dev/null @@ -1,10 +0,0 @@ -Сделать возможность убрать свой лайк. (пока не надо а сложность что надо больше проверок) - хотя можно и без проверки, просто за двойной лайк или за снятие двойное лайка. Будет двойное проникновение :)) тому кто изменил код клиента и убрал проверку на клиенте - и блокчейн заблокируется и всё. -поэтому просто на каждую реакцию добавиться убрать эту ракцию . -- это просто - -сделатьпотом что бы в солану_юзерс хранилось имя текущего блокчейна пользователя. Что бы потом можно было грузить именно актуальный ТО ЕСТЬ потом можно будет менять блокченый! - - - - -сделать сессион пасворд тоже ключём подписи устройства!! \ No newline at end of file diff --git a/DOC/doc_all_libs.md b/DOC/doc_all_libs.md deleted file mode 100644 index 12d8aa3..0000000 --- a/DOC/doc_all_libs.md +++ /dev/null @@ -1,22 +0,0 @@ -Перечень библиотек и их краткое описание - -shine-server-log -Статический “сиренный” метод для максимально заметного критического лога администратору - -shine-server-config -Минимальный конфиг-лоадер, который один раз читает application.properties и даёт доступ к параметрам. - -shine-server-geo -Утилиты, которые вытаскивают IP/язык/UA из Jetty WebSocket и (опционально) резолвят гео по IP с кэшем в БД. - -shine-server-crypto -Базовые крипто-утилиты для SHA-256 и Ed25519 (BouncyCastle) + проверка подписи/хэша для .bch сущностей и маленький self-test. - -shine-server-bd -Библиотека реалезующая всю работу с БД: - -shine-server-blockchain -Библиотека, которая задаёт единый бинарный формат блоков (RAW+signature+hash), парсит/валидирует “тело” блока по type/version, и проверяет целостность/подпись цепочки через SHA-256 + Ed25519 с привязкой к login и предыдущим хэшам. - -shine-server-protocol -Библиотека JSON-протокол поверх WebSocket для взаимодействия с клиентами. diff --git a/DOC/Описание БД.md b/DOC/Описание БД.md deleted file mode 100644 index 684098a..0000000 --- a/DOC/Описание БД.md +++ /dev/null @@ -1,209 +0,0 @@ -SHiNE — структура БД (актуальная версия) - -Перечень таблиц и назначение - -solana_users -Справочник пользователей: логин + ключ устройства + (опционально) Solana-ключ. -Базовая таблица, используется как FK почти везде. - -active_sessions -Активные сессии авторизации/работы клиента: секреты, тайминги, WebPush-данные, IP и информация о клиенте. - -users_params -Хранилище актуальных параметров пользователя. -Для каждой пары (login, param) хранится только самая новая версия по time_ms. - -ip_geo_cache -Кеш геолокации по IP для снижения нагрузки на внешние сервисы. - -blockchain_state -Агрегированное состояние блокчейна по blockchain_name: -лимиты, текущий размер, последний глобальный блок и состояние линий 0..7. - -blocks -Журнал всех блоков и сообщений. -Содержит историю событий: тексты, реакции, ответы, связи. -PRIMARY KEY намеренно отсутствует. - -connections_state ⭐ -Актуальное состояние связей между пользователями -(друг / контакт / подписка). -Обновляется автоматически на основе событий из blocks. - -message_stats ⭐ -Агрегированные счётчики лайков и ответов на конкретные сообщения. -Поддерживается триггерами из blocks. - -Таблицы подробно - -solana_users -login — TEXT PK — уникальный логин пользователя -client_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64)) -solana_key — TEXT NULL — публичный ключ Solana-аккаунта - -active_sessions -session_id — TEXT PK — идентификатор сессии -login — TEXT NOT NULL, FK → solana_users(login) -session_pwd — TEXT NOT NULL — секрет сессии -storage_pwd — TEXT NOT NULL — секрет storage -session_created_at_ms — INTEGER NOT NULL -last_authirificated_at_ms — INTEGER NOT NULL -push_endpoint — TEXT NULL -push_p256dh_key — TEXT NULL -push_auth_key — TEXT NULL -client_ip — TEXT NULL -client_info_from_client — TEXT NULL -client_info_from_request — TEXT NULL -user_language — TEXT NULL - -users_params -login — TEXT NOT NULL, FK → solana_users(login) -param — TEXT NOT NULL -time_ms — INTEGER NOT NULL -value — TEXT NOT NULL -client_key — TEXT NULL -signature — TEXT NULL - -Ограничение: -UNIQUE(login, param) - -Логика: -обновление принимается только если excluded.time_ms > users_params.time_ms - -ip_geo_cache -ip — TEXT PK -geo — TEXT NULL -updated_at_ms — INTEGER NOT NULL - -blockchain_state -blockchain_name — TEXT PK -login — TEXT NOT NULL, FK → solana_users(login) -blockchain_key — TEXT NOT NULL -size_limit — INTEGER NOT NULL -file_size_bytes — INTEGER NOT NULL -last_global_number — INTEGER NOT NULL (-1 = genesis) -last_global_hash — TEXT NOT NULL -updated_at_ms — INTEGER NOT NULL - -Линии 0..7: -для каждой линии: -lineX_last_number -lineX_last_hash - -blocks -login — TEXT NOT NULL -bch_name — TEXT NOT NULL -block_global_number — INTEGER NOT NULL -block_global_pre_hash — TEXT NOT NULL -block_line_index — INTEGER NOT NULL -block_line_number — INTEGER NOT NULL -block_line_pre_hash — TEXT NOT NULL -msg_type — INTEGER NOT NULL -msg_sub_type — INTEGER NOT NULL -block_bytes — BLOB NULL - -Ссылка на другой блок (nullable): -to_login -to_bch_name -to_block_global_number -to_block_hash - -connections_state ⭐ -Текущее агрегированное состояние связей. - -login — TEXT NOT NULL -rel_type — INTEGER NOT NULL -10 = FRIEND -20 = CONTACT -30 = FOLLOW -to_login — TEXT NOT NULL -to_bch_name — TEXT NOT NULL -to_block_global_number — INTEGER NULL -to_block_hash — TEXT NULL - -Ограничение: -UNIQUE(login, rel_type, to_login) - -message_stats ⭐ -Счётчики активности по целевому сообщению. - -to_login — TEXT NOT NULL -to_bch_name — TEXT NOT NULL -to_block_global_number — INTEGER NOT NULL -to_block_hash — TEXT NOT NULL -likes_count — INTEGER NOT NULL DEFAULT 0 -replies_count — INTEGER NOT NULL DEFAULT 0 - -UNIQUE: -(to_login, to_bch_name, to_block_global_number, to_block_hash) - -Триггеры БД (полная логика) - -3.1 Связи пользователей -trg_blocks_connection_state_ai -AFTER INSERT ON blocks - -Условие: -msg_type = 3 (connection) - -Добавление / обновление связи -msg_sub_type IN (10,20,30) -выполняется UPSERT в connections_state - -Удаление связи -msg_sub_type IN (11,21,31) -удаляется соответствующая связь: -11 → 10 -21 → 20 -31 → 30 - -Итог: -blocks — журнал событий -connections_state — всегда актуальное состояние - -3.2 Подсчёт лайков ⭐ -trg_blocks_message_stats_like_ai -AFTER INSERT ON blocks - -Условие: -msg_type = 2 (reaction) -msg_sub_type = 1 (like) - -Действие: -определяется цель по to_bch_name, to_block_global_number, to_block_hash -to_login вычисляется как -substr(to_bch_name, 1, length(to_bch_name) - 3) -выполняется UPSERT в message_stats -likes_count += 1 - -3.3 Подсчёт ответов ⭐ -trg_blocks_message_stats_reply_ai -AFTER INSERT ON blocks - -Условие: -msg_type = 1 (text) -msg_sub_type = 2 (reply) - -Действие: -цель определяется аналогично лайкам -выполняется UPSERT в message_stats -replies_count += 1 - -Индексы (смысл) - -idx_solana_users_login — поиск пользователя -idx_active_sessions_login — сессии пользователя -idx_users_params_login — параметры пользователя -idx_ip_geo_cache_updated_at — чистка кеша -idx_blockchain_state_login — блокчейны пользователя -idx_blockchain_state_updated_at — обслуживание -idx_blocks_chain_global — чтение цепочки -idx_blocks_to_target — реакции / ответы -idx_message_stats_target — быстрый доступ к счётчикам - -Итоговая модель мышления - -blocks — неизменяемый журнал событий -connections_state — проекция связей -message_stats — проекция активности -всё вычисляется детерминированно через триггеры \ No newline at end of file diff --git a/DOC/Протокол звонков (MVP).md b/DOC/Протокол звонков (MVP).md deleted file mode 100644 index d826d95..0000000 --- a/DOC/Протокол звонков (MVP).md +++ /dev/null @@ -1,75 +0,0 @@ -# Протокол звонков (MVP) - -Версия: browser-to-browser, runtime-only signaling. - -## Цели -- Технические сообщения звонка не сохраняются в БД direct_messages. -- Первый INVITE рассылается всем активным сессиям получателя и дублируется web push. -- Последующие сигналы идут только в конкретную sessionId и не дублируются в push. - -## Операции API - -### 1) CallInviteBroadcast -Отправляет общий вызов пользователю. - -Запрос payload: -- `toLogin: string` -- `callId: string` -- `type: 100` (INVITE) - -Поведение сервера: -- Рассылает `IncomingCallInvite` во все активные WS-сессии `toLogin`. -- В payload события передаёт: - - `fromLogin` - - `fromSessionId` (session инициатора) - - `toLogin` - - `callId` - - `type=100` - - `timeMs` -- Отправляет web push уведомление о входящем вызове. - -Ответ payload: -- `callId` -- `deliveredWsSessions` -- `deliveredFcmSessions` - -### 2) CallSignalToSession -Отправляет технический сигнал в конкретную сессию. - -Запрос payload: -- `toLogin: string` -- `targetSessionId: string` -- `callId: string` -- `type: int` -- `data: string` (для SDP/ICE/служебных строк) - -Поведение сервера: -- Ищет только `targetSessionId`. -- Проверяет, что сессия принадлежит `toLogin`. -- Отправляет `IncomingCallSignal` только в эту сессию. -- В БД ничего не сохраняет. -- Push не отправляет. - -Ответ payload: -- `delivered: boolean` - -## Коды type -- `100` INVITE -- `110` RINGING -- `120` ACCEPT -- `130` DECLINE_BUSY -- `140` TIMEOUT -- `150` HANGUP -- `200` OFFER -- `210` ANSWER -- `220` ICE - -## Правила UI/логики -- Если уже есть активный звонок и пришел новый INVITE -> автоответ `DECLINE_BUSY` без UI. -- После ACCEPT `callId` остаётся во всех OFFER/ANSWER/ICE сообщениях до конца звонка. -- При параллельных звонках A<->B допускается детерминированное правило, кто создаёт OFFER. - -## Тайминги MVP -- Ожидание подтверждения/реакции после INVITE: до 5с (у инициатора). -- Ожидание принятия у входящего звонка: 20с. -- Общий лимит ожидания до соединения: 22с. diff --git a/DOC/Что недоделано. И потом надо доделать b/DOC/Что недоделано. И потом надо доделать deleted file mode 100644 index d54ccd4..0000000 --- a/DOC/Что недоделано. И потом надо доделать +++ /dev/null @@ -1,9 +0,0 @@ - - -Дальше делать: -Описание форматов. -Запросы клиент-сервер. -Промт на клиента. - ---- -Потом в сервак дописать синхронизацию серверов. \ No newline at end of file diff --git a/Deploy Server/servers-inventory.md b/Deploy Server/servers-inventory.md deleted file mode 100644 index a7fa20c..0000000 --- a/Deploy Server/servers-inventory.md +++ /dev/null @@ -1,53 +0,0 @@ -# SHiNE Deployment Servers Inventory - -## Scope -This folder contains all deployment-related notes and server records for SHiNE. - -## Legacy Production Server -- Name: `VPS-02` (legacy) -- Access: `root@194.87.0.247` -- Current role: old production server -- Confirmed services: - - `coturn` is installed and active (`systemd: active/running`) - - `caddy` is installed (reported by project context; verify version on host if needed) -- TURN configuration observed on host: - - `listening-port=3478` - - `external-ip=194.87.0.247` - - `relay-ip=194.87.0.247` - - auth mode: `use-auth-secret` + `static-auth-secret` -- SHiNE deployment note: - - This host is used as current/legacy runtime for SHiNE. - - Gradle-based deployment is used in this project (see repository deploy tasks and scripts). - -## Target Production Server (Migration) -- Name: `VPS-05` (new) -- Access: `root@45.136.124.227` -- Planned role: new primary production server for gradual migration -- Baseline setup done: - - `ripgrep` installed - - user `player` created - - user `player` added to `sudo` group - - deployment directory created: `/home/player/SHiNE` -- Rule: - - All SHiNE-related runtime files and deployments on VPS-05 should be placed under `/home/player/SHiNE`. - -## Additional TURN Node -- Name: `promo-node-93` -- Access: `ubuntu@93.170.12.154` (and `player` user for SHiNE operations) -- Role: additional TURN node for SHiNE calls -- TURN setup: - - `coturn` installed and active - - `listening-port=3478` - - `tls-listening-port=5349` - - `use-auth-secret` + shared `static-auth-secret` - - relay UDP port range: `49152-50152` -- Runtime files: - - `/etc/turnserver.conf` - - `/home/player/SHiNE/coturn/turnserver.conf` -- Cleanup done: - - Disabled old reverse SSH tunnel (`reverse-ssh.service`) that exposed `0.0.0.0:1200 -> localhost:22` to `194.87.0.247`. - -## Next Migration Steps (recommended) -1. Install and configure runtime dependencies (JDK, Caddy, DB, TURN if required). -2. Mirror SHiNE deployment process from VPS-02 using existing Gradle deployment flow. -3. Move traffic gradually and validate logs/metrics before final cutover. diff --git a/Deploy/PRODUCTION_SERVERS.md b/Deploy/PRODUCTION_SERVERS.md new file mode 100644 index 0000000..fb08996 --- /dev/null +++ b/Deploy/PRODUCTION_SERVERS.md @@ -0,0 +1,96 @@ +# Production-серверы SHiNE + +## Короткий ответ + +По текущим данным репозитория у SHiNE описаны **два production-контура**: + +- `player@shineup.me` +- домен `shineup.me` +- IP `178.208.64.62` + +и + +- `player@193.8.215.70` +- домен `t.shineup.me` +- IP `193.8.215.70` + +Отдельно резервный хост `test.shineup.me` сейчас помечен как временно неработающий. + +## 1. Основной production-хост + +- SSH: `player@shineup.me` +- домен: `shineup.me` +- IP: `178.208.64.62` +- пользователь: `player` +- базовый путь: `/home/player` + +Основные каталоги: + +- проект SHiNE: `/home/player/SHiNE` +- серверный jar: `/home/player/SHiNE/shine-server/shine-server.jar` +- UI: `/home/player/SHiNE/shine-ui` +- данные: `/home/player/SHiNE/shine-server/data/` +- логи: `/home/player/SHiNE/shine-server/logs/app.log` + +Сервисы: + +- `shine-server.service` +- `caddy.service` + +Caddy: + +- активный конфиг: `/etc/caddy/Caddyfile` +- UI root: `/home/player/SHiNE/shine-ui` +- `/ws` проксируется на `127.0.0.1:7070` + +Deploy: + +- `./gradlew deployServerProduction` +- `./gradlew deployUIProduction` + +Правило: + +- любые изменения на `shineup.me` делать только после отдельного подтверждения пользователя. + +## 2. Второй production-сервер + +- SSH: `player@193.8.215.70` +- домен: `t.shineup.me` +- IP: `193.8.215.70` +- пользователь: `player` +- базовый путь: `/home/player` + +Роль: + +- второй production-контур SHiNE; +- использовать как production-сервер, несмотря на исторические имена deploy-задач + `deployServerTest2` / `deployUITest2`. + +Основные каталоги: + +- проект SHiNE: `/home/player/SHiNE` +- серверный jar: `/home/player/SHiNE/shine-server/shine-server.jar` +- UI: `/home/player/SHiNE/shine-ui` +- данные: `/home/player/SHiNE/shine-server/data/` +- логи: `/home/player/SHiNE/shine-server/logs/app.log` + +## 3. Связанные публичные production-публикации на том же хосте + +На этом же production-хосте есть отдельная публикация для `shine_payments`: + +- каталог: `/home/player/sites/test-solana-tickets.shineup.me` +- домены: + - `https://test-solana-tickets.shineup.me` + - `https://test-solana-tickets.shiningpeople.ru` + +Это не второй production-хост SHiNE, а отдельный сайт на том же сервере. + +## 4. Какие серверы не считать production + +Не production: + +- `test.shineup.me` (`93.170.12.154`) — резервный хост, временно не работает +- `t1.shineup.me` +- `t2.shineup.me` +- `t3.shineup.me` +- `t4.shineup.me` diff --git a/Deploy/README.md b/Deploy/README.md new file mode 100644 index 0000000..1f6d973 --- /dev/null +++ b/Deploy/README.md @@ -0,0 +1,41 @@ +# Deploy + +Подробности о том, где что задеплоено в SHiNE, нужно искать в папке `Deploy/`. + +Эта папка служит краткой картой окружений: + +- [TEST_SERVERS.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Deploy/TEST_SERVERS.md) — тестовые серверы и стенды; +- [PRODUCTION_SERVERS.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Deploy/PRODUCTION_SERVERS.md) — production-контур и связанные публичные публикации. + +Ниже краткая сводка. + +## Основные публичные контуры + +- Production SHiNE: + - `player@shineup.me` + - домен `shineup.me` + - IP `178.208.64.62` +- Второй production SHiNE: + - `player@193.8.215.70` + - домен `t.shineup.me` + - IP `193.8.215.70` +- Временно неработающий резервный test: + - `player@93.170.12.154` + - домен `test.shineup.me` + - IP `93.170.12.154` + +## Отдельный quad-devnet стенд + +На отдельном VPS `178.208.90.249` подняты 4 независимых test/devnet-инстанса: + +- `t1.shineup.me` +- `t2.shineup.me` +- `t3.shineup.me` +- `t4.shineup.me` + +## Важно + +- Production-контура SHiNE сейчас два: `shineup.me` и `t.shineup.me`. +- `test.shineup.me` сейчас помечен как временно неработающий резервный test-хост. +- `t1..t4.shineup.me` — это отдельные тестовые/devnet-контуры, не production. +- Любые изменения на `shineup.me` делать только после отдельного подтверждения пользователя. diff --git a/Deploy/TEST_SERVERS.md b/Deploy/TEST_SERVERS.md new file mode 100644 index 0000000..8ef01af --- /dev/null +++ b/Deploy/TEST_SERVERS.md @@ -0,0 +1,150 @@ +# Тестовые серверы SHiNE + +Этот файл описывает тестовые контуры, которые сейчас фигурируют в проекте. + +## 1. Исторический `test2`, теперь второй production-сервер + +- SSH: `player@193.8.215.70` +- Домен: `t.shineup.me` +- IP: `193.8.215.70` +- Назначение: второй production-контур SHiNE + +Структура: + +- каталог SHiNE: `/home/player/SHiNE` +- сервер: `/home/player/SHiNE/shine-server/shine-server.jar` +- UI: `/home/player/SHiNE/shine-ui` +- данные: `/home/player/SHiNE/shine-server/data/` +- логи: `/home/player/SHiNE/shine-server/logs/app.log` + +Сервисы: + +- `shine-server.service` +- `caddy.service` + +Deploy: + +- `./gradlew deployServer` +- `./gradlew deployServerTest2` +- `./gradlew deployUI` +- `./gradlew deployUITest2` + +Примечания: + +- этот хост больше не считать test-контуром; +- исторические имена deploy-задач `deployServerTest2` / `deployUITest2` сохранены, но сам хост считать production; +- при описании окружений перечислять его как второй production-сервер. + +## 2. Резервный test-сервер, временно неработающий + +- SSH: `player@93.170.12.154` +- Домен: `test.shineup.me` +- IP: `93.170.12.154` +- Назначение: резервный test + +Структура по проектным докам: + +- каталог SHiNE: `/home/player/SHiNE` +- сервер: `/home/player/SHiNE/shine-server/shine-server.jar` +- UI: `/home/player/SHiNE/shine-ui` +- данные: `/home/player/SHiNE/shine-server/data/` +- логи: `/home/player/SHiNE/shine-server/logs/app.log` + +Deploy: + +- `./gradlew deployServerTest` +- `./gradlew deployUITest` + +Примечания: + +- этот хост резервный; +- сейчас помечен как временно неработающий; +- использовать его без отдельной причины не нужно. + +## 3. Отдельный quad-devnet стенд `t1..t4` + +- VPS: `178.208.90.249` +- пользователь: `player` +- назначение: 4 независимых SHiNE-инстанса на Solana `devnet` + +Домены и логины: + +- `server_t1` -> `https://t1.shineup.me` +- `server_t2` -> `https://t2.shineup.me` +- `server_t3` -> `https://t3.shineup.me` +- `server_t4` -> `https://t4.shineup.me` + +Каталоги: + +- `/home/player/t1/server` +- `/home/player/t1/UI` +- `/home/player/t2/server` +- `/home/player/t2/UI` +- `/home/player/t3/server` +- `/home/player/t3/UI` +- `/home/player/t4/server` +- `/home/player/t4/UI` + +Подробная памятка на самом VPS: + +- `/home/player/Agents.md` + +Порты и systemd: + +- `t1` -> `7101` -> `shine-t1.service` +- `t2` -> `7102` -> `shine-t2.service` +- `t3` -> `7103` -> `shine-t3.service` +- `t4` -> `7104` -> `shine-t4.service` + +Что важно по конфигу каждого инстанса: + +- отдельный `/home/player/tX/server/application.properties` +- `server.port=710X` +- `server.SHiNE.login=server_tX` +- `db.path=data/shine.sqlite` +- `solana.cluster=devnet` +- `solana.rpcUrl=https://api.devnet.solana.com` +- `server.ui.indexPath=/home/player/tX/UI/index.html` +- `server.info.url=https://tX.shineup.me` + +UI каждого инстанса: + +- живёт в отдельной копии `shine-UI`; +- использует свой `js/deploy-config.js`; +- по умолчанию смотрит именно на свой `tX.shineup.me`. + +Caddy на стенде: + +- конфиг: `/etc/caddy/Caddyfile` +- статика: `/home/player/tX/UI` +- `/ws` проксируется на `127.0.0.1:710X` + +Operational-нюанс: + +- при одновременных рестартах возможны `HTTP 429` от `api.devnet.solana.com`; +- поэтому сервисы `shine-t1..shine-t4` лучше перезапускать по одному, с паузой. + +## 4. Что проверять первым делом + +Для любого test-контура полезны такие быстрые проверки: + +```bash +curl -I https://t.shineup.me +curl -I https://test.shineup.me +curl -I https://t1.shineup.me +curl -I https://t2.shineup.me +curl -I https://t3.shineup.me +curl -I https://t4.shineup.me +``` + +Для quad-devnet VPS: + +```bash +sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4 +``` + +Для основного/резервного test: + +```bash +sudo systemctl --no-pager --full status shine-server caddy +``` diff --git a/Dev_Docs/API/05_Technical_Requests_API.md b/Dev_Docs/API/05_Technical_Requests_API.md deleted file mode 100644 index 950aea4..0000000 --- a/Dev_Docs/API/05_Technical_Requests_API.md +++ /dev/null @@ -1,333 +0,0 @@ -# API для разработчиков: Технические запросы - -Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии. - -Сейчас здесь шесть методов: - -- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения; -- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети; -- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков; -- `ClientErrorLog` — отправка клиентской ошибки в серверный лог; -- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер; -- `CallDeliveryReport` — диагностический отчёт клиента о доставке/установке звонка. - -Логика раздела такая: - -- `Ping` нужен для регулярной проверки, что соединение всё ещё живо; -- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла. - -Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов. - -## 1. `Ping` - -### Назначение - -Служебный keep-alive запрос. - -Клиент может отправлять его периодически, чтобы: - -- поддерживать активное WebSocket-соединение; -- понимать, что сервер отвечает; -- при необходимости получать текущее серверное время. - -### Запрос - -```json -{ - "op": "Ping", - "requestId": "ping-001", - "payload": { - "ts": 1774700000123 - } -} -``` - -Поле `ts` в запросе необязательно для логики сервера. Сервер его не валидирует и не использует для принятия решения. - -### Успешный ответ - -```json -{ - "op": "Ping", - "requestId": "ping-001", - "status": 200, - "ok": true, - "payload": { - "ts": 1774700000456 - } -} -``` - -### Специфические коды ошибок `Ping` - -- У `Ping` нет специальных прикладных ошибок. -- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`. - ---- - -## 2. `GetServerInfo` - -### Назначение - -Запрос публичной информации о сервере. - -Он нужен клиенту для выбора сервера в децентрализованной сети. По этому запросу клиент может: - -- проверить, что сервер вообще доступен; -- показать URL и версию сервера; -- показать физический регион или адрес размещения; -- показать описание сервера; -- показать поле `origin` как комментарий о природе этого узла; -- показать дополнительную текстовую информацию. - -Этот запрос доступен без авторизации. - -### Источник данных - -- `version` берётся из Gradle build и подставляется в `application.properties`; -- остальные поля читаются из настроек сервера; -- если значение в конфиге не задано, сервер возвращает пустую строку. - -### Запрос - -```json -{ - "op": "GetServerInfo", - "requestId": "srv-001", - "payload": { - } -} -``` - -### Успешный ответ - -```json -{ - "op": "GetServerInfo", - "requestId": "srv-001", - "status": 200, - "ok": true, - "payload": { - "url": "wss://node.example.org/ws", - "version": "1.0", - "physicalRegion": "Грузия, Тбилиси", - "description": "Public community SHiNE node", - "origin": "Community-operated node", - "extraInfo": "IPv4 + IPv6; test federation enabled" - } -} -``` - -### Поля ответа - -- `url` — публичный URL сервера. -- `version` — версия сервера из Gradle build. -- `physicalRegion` — физический регион или адрес размещения сервера. -- `description` — человекочитаемое описание сервера. -- `origin` — комментарий о том, какой это сервер. -- `extraInfo` — любая дополнительная информация о сервере. - -### Специфические коды ошибок `GetServerInfo` - -- У `GetServerInfo` нет специальных прикладных ошибок при штатной работе. -- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`. - ---- - -## 3. `GetCallIceConfig` - -Доступно только после успешной авторизации. - -### Запрос - -```json -{ - "op": "GetCallIceConfig", - "requestId": "ice-001", - "payload": { - } -} -``` - -### Успешный ответ - -```json -{ - "op": "GetCallIceConfig", - "requestId": "ice-001", - "status": 200, - "ok": true, - "payload": { - "stunUrls": ["stun:stun.example.org:3478"], - "turnUrls": ["turn:turn.example.org:3478?transport=udp"], - "turnUsername": "user", - "turnPassword": "password", - "turnServers": [ - { - "id": "primary", - "urls": ["turn:turn.example.org:3478?transport=udp"], - "username": "user", - "password": "password" - } - ], - "turnEnabled": true, - "generatedAtMs": 1774700000123, - "expiresAtMs": 1774700300123, - "ttlSec": 300 - } -} -``` - -### Специфические коды ошибок `GetCallIceConfig` - -- `422 / NOT_AUTHENTICATED` — требуется авторизация. - ---- - -## 4. `ClientErrorLog` - -### Запрос - -```json -{ - "op": "ClientErrorLog", - "requestId": "err-001", - "payload": { - "kind": "global_error", - "message": "TypeError: failed", - "stack": "...", - "sourceUrl": "https://shineup.me/app.js", - "lineNumber": 10, - "columnNumber": 20, - "route": "#/channel-view/own-0", - "href": "https://shineup.me/#/channel-view/own-0", - "userAgent": "...", - "clientTs": 1774700000123, - "requestOp": "GetChannelMessages", - "requestIdRef": "GetChannelMessages-123", - "contextJson": "{\"screen\":\"channels\"}" - } -} -``` - -### Успешный ответ - -```json -{ - "op": "ClientErrorLog", - "requestId": "err-001", - "status": 200, - "ok": true, - "payload": { - "serverTs": 1774700000456, - "accepted": true - } -} -``` - -### Специфические коды ошибок `ClientErrorLog` - -- `400 / BAD_FIELDS` — обязательные поля ошибки не заполнены. - ---- - -## 5. `ClientDebugLog` - -### Запрос - -```json -{ - "op": "ClientDebugLog", - "requestId": "dbg-001", - "payload": { - "runId": "ui-run-1", - "level": "info", - "message": "opened channels tab", - "details": "{\"route\":\"#/channels\"}" - } -} -``` - -### Успешный ответ - -```json -{ - "op": "ClientDebugLog", - "requestId": "dbg-001", - "status": 200, - "ok": true, - "payload": { - "accepted": true, - "serverTs": 1774700000456 - } -} -``` - -### Специфические коды ошибок `ClientDebugLog` - -- `400 / BAD_FIELDS` — поле `message` не заполнено. - ---- - -## 6. `CallDeliveryReport` - -### Запрос - -```json -{ - "op": "CallDeliveryReport", - "requestId": "call-report-001", - "payload": { - "type": "outgoing_failed", - "value": "{\"reason\":\"ice_failed\",\"callId\":\"call-1\"}" - } -} -``` - -### Успешный ответ - -```json -{ - "op": "CallDeliveryReport", - "requestId": "call-report-001", - "status": 200, - "ok": true, - "payload": { - "serverTs": 1774700000456, - "accepted": true - } -} -``` - -### Специфические коды ошибок `CallDeliveryReport` - -- `400 / BAD_FIELDS` — поле `type` не заполнено. - ---- - -## 7. Короткое резюме - -- `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо. -- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле. -- `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации. -- `ClientErrorLog`, `ClientDebugLog`, `CallDeliveryReport` используются для диагностики клиента и звонков. - - -## 8. Прямое техническое сообщение в конкретную сессию - -На текущий момент в публичном JSON API этого документа **нет отдельного RPC** для отправки произвольного технического сообщения в конкретную сессию пользователя (по `sessionId`). - -Что уже есть в системе: - -- сервер хранит `sessionId` активной сессии; -- есть `ListSessions`, чтобы клиент получил список sessionId своего пользователя; -- у сервера есть внутренний реестр активных WS-подключений по `sessionId`. - -Чего не хватает для полноценной фичи «direct tech message by sessionId»: - -1. отдельная API-операция (например, `SendSessionTechMessage`); -2. правило авторизации (кто имеет право писать в чужую/свою сессию); -3. унифицированный формат payload и события доставки; -4. коды ошибок (`SESSION_OFFLINE`, `SESSION_NOT_FOUND`, `FORBIDDEN` и т.п.). - -Итог: как инфраструктурная база это почти готово, но нужен отдельный RPC-слой и политика доступа. diff --git a/Dev_Docs/Blockchain/CHANGELOG.md b/Dev_Docs/Blockchain/CHANGELOG.md deleted file mode 100644 index 3feb46b..0000000 --- a/Dev_Docs/Blockchain/CHANGELOG.md +++ /dev/null @@ -1,48 +0,0 @@ -# История изменений документации блокчейна - -## 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)`: - - обновлён перечень типов в `11_TEXT_Blocks.md`; - - обновлена быстрая карта типов в `00_Blockchain_Formats_and_Block_Types.md`. -- Уточнено API-описание поддержанных подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`. -- В документе `Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`. - -## 2026-05-20 11:34:17 +0300 -- Базовый коммит-ориентир: `a53444b`. -- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы: - - `60/61` — `known_person / unknown_person` (знаю этого человека); - - `70/71` — `shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий); - - `74/75` — `shine_seen / shine_unseen` (мало знаком, но видел сияющим). -- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`. - -## 2026-05-19 20:30:21 +0300 -- Базовый коммит-ориентир: `7986184`. -- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения. -- Явно закреплено, что отдельного `DELETE`-подтипа нет, удаление выполняется edit-блоком. - -## 2026-05-19 00:22:46 +0300 -- Базовый коммит-ориентир: `c27da63a3e65`. -- Актуализирован `README.md` как точка входа для MVP-документации по протоколу. -- В документации явно зафиксировано, что `channelType=100` и `channelType=200` присутствуют в формате, но пока не используются в UI. -- Актуализирован перечень REACTION-подтипов: добавлен `REACTION_UNLIKE (subType=2)`. -- Актуализирован перечень CONNECTION-подтипов: добавлены `SPOUSE/PARENT/CHILD/SIBLING` и обратные операции. -- В документ `02_Blockchain_Kinds_and_Lines.md` добавлены фактические серверные правила валидации line-полей. -- Обновлён корневой `AGENTS.md`: формат блокчейна менять только после явного подтверждения пользователя и с предварительным предупреждением. - -## 2026-05-13 00:02:32 +0300 -- Базовый коммит-ориентир: `f63f40f1eb2f`. -- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`. -- Зафиксированы типы каналов: `0=stories`, `1=public`, `100=personal`, `200=group`. -- Серверная уникальность имени канала изменена на `owner + type + name(slug)`. -- Root-канал `0` переименован в `stories` на уровне API-чтения. -- Для персонального канала (`type=100`) включена сборка парного потока при чтении (`A->B` + `B->A`, если существует). -- Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении. -- Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками). -- В `AGENTS.md` добавлено обязательное правило актуализации документации в `Dev_Docs/Blockchain/`. diff --git a/Dev_Docs/Blockchain/sync-between-servers.md b/Dev_Docs/Blockchain/sync-between-servers.md deleted file mode 100644 index 427f954..0000000 --- a/Dev_Docs/Blockchain/sync-between-servers.md +++ /dev/null @@ -1,100 +0,0 @@ -# Синхронизация блоков и DM между серверами SHiNE - -Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE. - -## 1. Зачем нужна синхронизация - -Пользователи SHiNE могут быть «приписаны» к разным серверам. -Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y): - -1. Сервер X принимает сообщение; -2. Сервер X должен переслать DM-блок серверу Y; -3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B. - -Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться, -чтобы любой партнёрский сервер мог отдать полную историю пользователя. - -## 2. Список серверов синхронизации (`sync_servers`) - -Каждый сервер регистрирует в своей Solana PDA список `sync_servers` — -логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется. - -- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера. -- Адрес каждого партнёрского сервера читается из его PDA на Solana. -- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`. - -## 3. Что синхронизируется - -### 3.1 Личные сообщения (DM) - -- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt). -- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя. -- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии. -- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`. - -### 3.2 Блоки пользовательского блокчейна - -- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него. -- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`. -- Порядок блоков сохраняется (по глобальному номеру блока и хэшу). -- Дедупликация по глобальному номеру блока и хэшу. - -## 4. Протокол синхронизации (целевой, не реализован) - -### 4.1 Межсерверное соединение - -- Серверы устанавливают постоянное WebSocket-соединение друг с другом. -- Адрес партнёра определяется по `server_address` из его Solana PDA. -- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA). -- При разрыве — переподключение с экспоненциальным backoff. - -### 4.2 Доставка новых данных (push) - -- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам. -- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff. - -### 4.3 Начальная синхронизация (backfill) - -- При первом подключении к партнёру серверы обмениваются «курсорами» состояния: - последний глобальный номер блока, последний известный DM-ключ. -- Сервер с более полной историей досылает недостающее партнёру. - -### 4.4 Разрешение конфликтов - -- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока. - Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`). -- DM: конфликтов нет, `message_key` уникален. - -## 5. Маршрутизация DM между серверами - -При отправке DM от пользователя A к пользователю B: - -1. Клиент A отправляет пару блоков на свой сервер X. -2. Сервер X определяет, на каком сервере зарегистрирован пользователь B. - - Сначала проверяет локально (если B зарегистрирован на X). - - Иначе читает PDA пользователя B из Solana и смотрит `access_servers`. - - Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM. -3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки. - -Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения). - -## 6. Безопасность - -- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое. -- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа). -- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере. - -## 7. Статус реализации - -| Компонент | Статус | -|-----------|--------| -| Регистрация серверной PDA в Solana | ✅ Реализовано | -| Чтение `sync_servers` из PDA | Нужна реализация | -| Межсерверный WebSocket-канал | Нужна реализация | -| Push новых DM партнёрам | Нужна реализация | -| Push блоков блокчейна партнёрам | Нужна реализация | -| Backfill при первом подключении | Нужна реализация | -| Маршрутизация DM через access_servers | Нужна реализация (заглушка) | - -Текущая версия сервера работает без межсерверной синхронизации. -Синхронизация — задача следующего этапа разработки. diff --git a/Dev_Docs/Future_Features/far/README.md b/Dev_Docs/Future_Features/far/README.md deleted file mode 100644 index fa0cb78..0000000 --- a/Dev_Docs/Future_Features/far/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Дальнее будущее - -Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании. - -## Идеи - -- `2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через сервер SHiNE или WebRTC DataChannel, а также файловый обмен чанками с хранением на SD-карте. diff --git a/Dev_Docs/Future_Features/near/2026-05-25_1106_telegram_agent_players.md b/Dev_Docs/Future_Features/near/2026-05-25_1106_telegram_agent_players.md deleted file mode 100644 index 2022dc0..0000000 --- a/Dev_Docs/Future_Features/near/2026-05-25_1106_telegram_agent_players.md +++ /dev/null @@ -1,94 +0,0 @@ -# Telegram-агент для разрешённых игроков - -- Горизонт: - `near` -- Ориентир: - сегодня/завтра -- Статус: - `proposal` - -## Кратко - -Нужно расширить `SHiNE-agent-bot-coder`, чтобы агент мог принимать личные сообщения от заранее разрешённых пользователей, вести по каждому отдельную рабочую папку и историю, помогать им с обсуждениями/документами без изменения кода, а краткий результат публиковать в общий канал. - -## Пользовательский сценарий - -1. Разрешённый пользователь пишет агенту в личные сообщения текстом или голосом. -2. Голосовое сообщение распознаётся так же, как сейчас распознаются voice/audio-задачи. -3. Сервис определяет пользователя по разрешённому списку логинов. -4. Для пользователя используется отдельная папка в `Players/`. -5. Codex запускается с системным контекстом: от имени какого человека он работает, где лежит его папка, какие у него локальные инструкции. -6. Агент может читать код и документацию проекта, но писать должен только в папку этого пользователя, если нет отдельного согласования на изменение общего проекта. -7. После ответа пользователю агент отправляет в общий канал короткую сводку двумя сообщениями или двумя блоками: вопрос пользователя и полученный ответ. -8. Команда `/new` или `New` сбрасывает только сессию этого пользователя. - -## Предлагаемая структура - -- `Players/` - - `Ivan/` - - `AGENTS.md` - - `history/` - - `files/` - - `Sergey/` - - `AGENTS.md` - - `history/` - - `files/` - - `Milana/` - - `AGENTS.md` - - `history/` - - `files/` - -Имена папок можно уточнить после получения точных Telegram-логинов. - -## Что нужно сделать - -1. Добавить конфигурацию разрешённых Telegram-пользователей. -2. Описать соответствие `telegram username -> имя игрока -> папка`. -3. Создавать или использовать отдельную историю диалога для каждого игрока. -4. Поддержать личные сообщения от разрешённых пользователей. -5. Запретить постановку задач от неизвестных пользователей. -6. Для групп/каналов оставить текущую логику: команды Айдара имеют приоритет. -7. При запуске Codex для игрока добавлять отдельный системный контекст: - - имя пользователя; - - путь к его папке; - - правило записи только в эту папку; - - путь к персональному `AGENTS.md`. -8. После ответа игроку отправлять краткую сводку в общий канал. -9. Поддержать `/new`/`New` как сброс только персональной сессии игрока. -10. Добавить защиту от случайного изменения общего кода в режиме игрока. - -## Вопросы перед реализацией - -1. Точные Telegram-логины Ивана, Сергея и Миланы. -2. Какой общий канал использовать для сводок: текущий `@shine_writing` или отдельный чат. -3. Нужно ли отправлять в общий канал полный текст вопроса/ответа или краткую выжимку. -4. Нужно ли пересылать вложения игроков в общий канал или только текстовые сводки. -5. Разрешить ли игрокам читать все документы проекта, включая технические заметки деплоя. -6. Что делать, если пользователь просит изменить код: отказать, создать предложение в своей папке или просить подтверждение Айдара. -7. Нужны ли русские имена папок (`Иван`, `Сергей`, `Милана`) или ASCII-имена (`Ivan`, `Sergey`, `Milana`). -8. Нужно ли хранить истории игроков в общей папке сервиса или внутри `Players//history/`. - -## Риски и ограничения - -- Нужно аккуратно разделить режим Айдара и режим игрока, чтобы игроки не могли случайно запустить изменение общего кода. -- Нужно не смешать истории разных пользователей. -- Нужно ограничить публикацию в общий канал, чтобы не утекали личные или слишком длинные ответы. -- Нужна проверка Telegram-идентификации: username может меняться, поэтому желательно хранить и `user_id`. - -## Документы, которые обновить при реализации - -- `SHiNE-agent-bot-coder/AGENTS.md` -- `SHiNE-agent-bot-coder/AGENT.md` -- `SHiNE-agent-bot-coder/README.md` -- `Dev_Docs/deploy/agent-bot-coder-local-systemd.md`, если появятся новые переменные окружения или настройки сервиса. - -## Минимальная проверка - -1. Айдар по-прежнему может ставить задачи из `@shine_writing`. -2. Неизвестный пользователь не ставит задачу в очередь. -3. Разрешённый игрок пишет личное текстовое сообщение и получает ответ. -4. Разрешённый игрок отправляет voice, оно распознаётся и обрабатывается. -5. История одного игрока не попадает в историю другого. -6. `/new` сбрасывает только историю текущего игрока. -7. Сводка вопрос/ответ появляется в общем канале. -8. В режиме игрока агент не пишет за пределы `Players//` без отдельного подтверждения. diff --git a/Dev_Docs/Pending_Features/2026-06-19_1605_call-decline-ui-and-ring-stop.md b/Dev_Docs/Pending_Features/2026-06-19_1605_call-decline-ui-and-ring-stop.md deleted file mode 100644 index 9b57221..0000000 --- a/Dev_Docs/Pending_Features/2026-06-19_1605_call-decline-ui-and-ring-stop.md +++ /dev/null @@ -1,22 +0,0 @@ -# Быстрое скрытие экрана звонка и остановка гудков при отклонении - -- краткое описание фичи: - - Исправлена нижняя подпись вкладки личных сообщений на `личные`. - - Исправлена логика звонка, когда одна из входящих сессий отклоняет вызов до принятия: исходящая сессия теперь должна прекращать гудки даже если ранее `RINGING` пришёл от другой входящей сессии. - - На устройстве, где пользователь нажимает отмену/сброс звонка, экран вызова теперь скрывается сразу локально, без ожидания сетевого ответа. - -- что именно проверять: - - В нижней панели с 5 кнопками подпись первой кнопки должна отображаться как `личные`. - - Сценарий: один пользователь звонит, у второго входящий вызов приходит на несколько устройств; на любом одном входящем устройстве нажать `Сбросить`. - - Проверить, что у звонящего сразу прекращаются гудки и экран вызова корректно завершается. - - Проверить, что на устройстве, где нажали `Сбросить` или `Положить трубку`, overlay звонка исчезает сразу, без заметной задержки. - - Проверить, что после принятия звонка на одном устройстве поздние отмены с других устройств не ломают уже выбранную пару соединения. - -- ожидаемый результат: - - Подпись в нижней панели корректная. - - При отклонении входящего звонка любым устройством звонящего не оставляет в состоянии бесконечных гудков. - - Локальный экран звонка скрывается мгновенно после нажатия кнопки отмены. - - Уже зафиксированный сценарий соединения после `ACCEPT` не сбивается другими сессиями. - -- статус: - - `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-19_1742_turn-uzel-93-only.md b/Dev_Docs/Pending_Features/2026-06-19_1742_turn-uzel-93-only.md deleted file mode 100644 index afee8ba..0000000 --- a/Dev_Docs/Pending_Features/2026-06-19_1742_turn-uzel-93-only.md +++ /dev/null @@ -1,14 +0,0 @@ -# Отключение устаревшего TURN-узла `45.136.124.227` - -- краткое описание: - - из конфигурации звонков убран устаревший TURN-узел `45.136.124.227:3478`; - - основным и единственным выдаваемым TURN-узлом оставлен `93.170.12.154:3478`. -- что проверять: - - сделать несколько тестовых звонков между разными устройствами/сетями; - - убедиться, что звонок доходит до стадии соединения и появляется звук; - - убедиться, что в логах `CallDeliveryReport` больше не фигурирует `45.136.124.227`. -- ожидаемый результат: - - клиентам больше не выдаётся устаревший TURN-адрес; - - звонки не заваливаются из-за попыток использовать отключённый TURN-узел. -- статус: - - pending diff --git a/Dev_Docs/Pending_Features/2026-06-19_1835_fix-stop-call-push-self-kill.md b/Dev_Docs/Pending_Features/2026-06-19_1835_fix-stop-call-push-self-kill.md deleted file mode 100644 index 891e248..0000000 --- a/Dev_Docs/Pending_Features/2026-06-19_1835_fix-stop-call-push-self-kill.md +++ /dev/null @@ -1,15 +0,0 @@ -# Фикс самообрыва звонка из-за `stop_call` push своей же сессии - -- краткое описание: - - исправлена ситуация, когда активный звонок мог оборваться сразу после соединения; - - причина была в том, что `stop_call` push, предназначенный для других сессий того же пользователя, обрабатывался и в исходной сессии. -- что проверять: - - открыть несколько вкладок/устройств одного пользователя; - - принять звонок на одной сессии; - - убедиться, что активная сессия не обрывает звонок сразу после соединения; - - убедиться, что лишние сессии при этом закрывают свой локальный экран звонка. -- ожидаемый результат: - - звонок не завершается сразу после `call_connected`; - - `accepted_on_other_device` и связанные `stop_call` события больше не убивают исходную активную сессию. -- статус: - - pending diff --git a/Dev_Docs/Pending_Features/2026-06-19_1905_call-push-target-session-fix.md b/Dev_Docs/Pending_Features/2026-06-19_1905_call-push-target-session-fix.md deleted file mode 100644 index 05a2f13..0000000 --- a/Dev_Docs/Pending_Features/2026-06-19_1905_call-push-target-session-fix.md +++ /dev/null @@ -1,16 +0,0 @@ -# Фикс привязки call push к целевой sessionId - -- краткое описание: - - push-события `incoming_call` и `stop_call` теперь помечаются целевой `sessionId`; - - UI и service worker обрабатывают call push только для своей целевой сессии; - - `stop_call` для лишних сессий закрывает локальный экран тихо, без обратных сигналов и без лишних тех-сообщений. -- что проверять: - - держать несколько сессий одного пользователя в одном браузере/на одном origin; - - позвонить этому пользователю и убедиться, что входящий экран закрывается корректно только на целевых сессиях; - - после `ACCEPT` одной сессии остальные должны тихо убрать экран вызова и не ломать выбранную пару; - - после отмены входящей сессией исходящая сессия должна централизованно завершить сценарий. -- ожидаемый результат: - - push одного session endpoint больше не влияет на чужие сессии этого же origin; - - исчезают ложные `stop_call_push:accepted_on_other_device` и `terminal_call_signal_150` на неправильных сессиях. -- статус: - - pending diff --git a/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md b/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md deleted file mode 100644 index 2966137..0000000 --- a/Dev_Docs/Pending_Features/2026-06-19_2015_esp32_pairing_requests.md +++ /dev/null @@ -1,24 +0,0 @@ -# ESP32 homeserver: заявки на подключение устройств - -- краткое описание фичи: - - на ESP32 homeserver в `SETTINGS` добавлен первый пункт `Device requests`, который появляется только после авторизации homeserver в SHiNE; - - экран показывает список активных pairing-заявок, позволяет открыть каждую заявку и подтвердить или отклонить её; - - формат кода подключения изменён на `10` цифр и показывается как `5` пар. - -- что проверять: - - на обычном клиенте и в wallet-plugin код отображается как `XX XX XX XX XX`; - - на доверенном веб-клиенте экран `Подключить по коду` показывает все активные заявки без поля ручного ввода; - - на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым; - - `REFRESH` реально загружает активные заявки; - - на экране видно две плитки, список листается вертикально; - - client-session заявка после `YES` подключается с передачей только `client key`; - - wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session; - - `NO` отклоняет заявку и она исчезает из списка активных. - -- ожидаемый результат: - - все три клиента используют единый формат кода; - - активные заявки видны без ручного ввода кода; - - ESP32 может одобрять и отклонять живые pairing-заявки пользователя. - -- статус: - - pending diff --git a/Dev_Docs/Pending_Features/2026-06-20_1835_test_deploy_contour.md b/Dev_Docs/Pending_Features/2026-06-20_1835_test_deploy_contour.md deleted file mode 100644 index 43ff8d0..0000000 --- a/Dev_Docs/Pending_Features/2026-06-20_1835_test_deploy_contour.md +++ /dev/null @@ -1,22 +0,0 @@ -# Тестовые deploy-контуры `test2.shineup.me` и `test.shineup.me` - -- краткое описание: - - default deploy-задачи `deployServer` и `deployUI` переведены на основной тестовый сервер `test2.shineup.me`; - - production-задачи вынесены в `deployServerProduction` и `deployUIProduction`; - - `test.shineup.me` оставлен как резервный тестовый сервер без обычного deploy по умолчанию. - -- что проверять: - - `./gradlew deployServer` и `./gradlew deployUI` действительно направлены на `test2.shineup.me`; - - `./gradlew deployServerProduction` и `./gradlew deployUIProduction` больше не используются как default; - - `https://test2.shineup.me` открывает UI; - - `wss://test2.shineup.me/ws` отвечает; - - на `test2.shineup.me` после deploy есть копия продовой `shine.sqlite` и `.bch`. - -- ожидаемый результат: - - default deploy идёт только на `test2.shineup.me`; - - production `shineup.me` меняется только после отдельного подтверждения; - - `test.shineup.me` остаётся резервным тестовым сервером; - - тестовый deploy не гоняет удалённые тесты и не создаёт пустую БД. - -- статус: - - pending diff --git a/Dev_Docs/Pending_Features/2026-06-21_1226_fix_dm_chatid_lowercase.md b/Dev_Docs/Pending_Features/2026-06-21_1226_fix_dm_chatid_lowercase.md deleted file mode 100644 index ce8b296..0000000 --- a/Dev_Docs/Pending_Features/2026-06-21_1226_fix_dm_chatid_lowercase.md +++ /dev/null @@ -1,19 +0,0 @@ -# Исправление chatId личных сообщений через lowercase - -- краткое описание фичи: - - В клиентском UI SHiNE для личных сообщений технический `chatId` теперь канонизируется через `trim().toLowerCase()` при приёме DM, открытии чата и восстановлении сообщений из IndexedDB. - - Цель: исключить рассинхрон, когда unread-индикатор есть, а входящие сообщения конкретного собеседника не видны из-за разного регистра логина. - -- что именно проверять: - - Отправить личные сообщения между двумя пользователями, у одного из которых логин отображается с заглавными буквами. - - Убедиться, что входящие сообщения показываются внутри открытого чата, а не только в общем unread-индикаторе. - - Перезагрузить страницу и проверить, что история чата после гидрации из IndexedDB остаётся в одном диалоге. - - Проверить, что переход в чат из списка диалогов и из графа связей открывает тот же диалог без дублирования. - -- ожидаемый результат: - - Все сообщения одного собеседника попадают в один и тот же DM-чат независимо от регистра логина. - - Общий unread, список диалогов и содержимое открытого чата совпадают между собой. - - После перезагрузки UI не появляется отдельный дубль диалога с тем же логином в другом регистре. - -- статус: - - pending diff --git a/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md b/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md deleted file mode 100644 index 416f383..0000000 --- a/Dev_Docs/Pending_Features/2026-06-21_1735_esp32_wallet_selector_and_extension_rpc.md +++ /dev/null @@ -1,27 +0,0 @@ -# ESP32 выбор кошелька и wallet RPC для browser extension - -- краткое описание: - - в ESP32 заменён домашний блок `баланс + QR` на единый вход в экран кошелька; - - добавлен выбор активного кошелька `ClientKey / RootKey / Custom`; - - для browser extension добавлен первый RPC `get_wallet_public_key` через существующий `wallet-session` и `CallSignalToSession`; - - в popup расширения добавлен запрос текущего кошелька и копирование `publicKeyBase58`. - -- что проверять: - - на ESP32 после ввода секрета на главном экране видна кнопка `Кошелёк: ...`; - - экран `WALLET` открывается и показывает текущий тип кошелька; - - экран `WALLET_SELECT` переключает `ClientKey`, `RootKey` и `Custom`; - - для `Custom` открывается ввод имени и после сохранения derivation работает; - - `Показать баланс кошелька` читает баланс именно активного кошелька; - - `Показать QR-код кошелька` показывает QR и адрес именно активного кошелька; - - browser extension после подключения wallet-session может запросить текущий кошелёк у ESP32; - - extension показывает тип кошелька, полный `publicKeyBase58`, результат проверки через PDA и копирует ключ в буфер; - - для `client.key` и `root.key` проверка через PDA даёт ожидаемое совпадение. - -- ожидаемый результат: - - активный кошелёк на ESP32 реально влияет на баланс, QR и ответ `get_wallet_public_key`; - - browser extension получает ответ без ручного ввода `walletSelector`; - - homeserver выбирается из опубликованных в PDA sessions и запрос приходит в нужное устройство; - - копирование ключа из extension работает. - -- статус: - - pending diff --git a/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md b/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md deleted file mode 100644 index 2a922b1..0000000 --- a/Dev_Docs/Pending_Features/2026-06-21_2045_esp32_english_ui_and_trusted_login_fix.md +++ /dev/null @@ -1,18 +0,0 @@ -# ESP32 English UI and trusted login fix - -- Краткое описание: - переведён экранный UI ESP32 homeserver на английский язык; добавлена локальная инструкция для AGENTS по ограничению кириллицы; исправлено падение `StartTrustedDeviceLogin`, когда у пользователя ещё нет записи `esp_pairing_settings`. - -- Что проверять: - 1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском. - 2. Проверить сценарий выбора `ClientKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения. - 3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login. - 4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`. - 5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше. - -- Ожидаемый результат: - - ESP32 UI читается на устройстве без кириллических строк. - - Подключение через trusted-device login стартует без server-side `INTERNAL_HANDLER_ERROR`, даже если настройки pairing ещё ни разу не сохранялись. - -- Статус: - `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-21_2125_browser_wallet_side_panel.md b/Dev_Docs/Pending_Features/2026-06-21_2125_browser_wallet_side_panel.md deleted file mode 100644 index ca1d167..0000000 --- a/Dev_Docs/Pending_Features/2026-06-21_2125_browser_wallet_side_panel.md +++ /dev/null @@ -1,20 +0,0 @@ -# Browser wallet side panel - -- Краткое описание: - browser extension `SHiNE Wallet` переведён с toolbar popup на штатный Chromium side panel. - -- Что проверять: - 1. Перезагрузить unpacked extension в Chromium-браузере. - 2. Нажать на иконку `SHiNE Wallet` в toolbar. - 3. Убедиться, что открывается side panel, а не всплывающий popup. - 4. Проверить, что панель можно держать открытой при навигации по сайтам. - 5. Проверить сценарии `Подключить`, `Отключить`, `Обновить устройства`, `Запросить кошелёк`. - 6. Проверить, что сторона панели управляется браузером, а UI корректно выглядит и слева, и справа. - -- Ожидаемый результат: - - расширение открывается в штатной боковой панели Chromium; - - UI работает так же, как раньше в popup; - - панель остаётся доступной как постоянная колонка браузера. - -- Статус: - `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md b/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md deleted file mode 100644 index 1573d9f..0000000 --- a/Dev_Docs/Pending_Features/2026-06-21_2235_wallet_provider_and_esp_sign_transaction.md +++ /dev/null @@ -1,23 +0,0 @@ -# Wallet provider and ESP sign_transaction - -- Краткое описание: - browser extension `SHiNE Wallet` теперь внедряет `window.solana` для сайтов и умеет выполнять `connect` и `signTransaction`; подпись транзакции уходит на ESP32 через wallet RPC `sign_transaction`, а подтверждение делается на устройстве. - -- Что проверять: - 1. Перезагрузить unpacked extension в Chromium. - 2. Открыть сайт/тестовую страницу, которая вызывает `window.solana.connect()`. - 3. Подтвердить подключение кошелька и убедиться, что сайт получает публичный ключ. - 4. Открыть сайт/тестовую страницу, которая вызывает `window.solana.signTransaction(...)`. - 5. Убедиться, что на ESP32 открывается экран `SIGN REQUEST` с комментарием. - 6. Проверить оба варианта: - - `APPROVE` возвращает сайту подписанную транзакцию; - - `REJECT` возвращает отказ. - 7. Проверить сценарии для `ClientKey`, `RootKey`, `Custom`. - -- Ожидаемый результат: - - сайт может подключить кошелёк через provider расширения; - - транзакция подписывается только после подтверждения на ESP32; - - отказ на ESP32 корректно доходит до сайта. - -- Статус: - `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-22_0135_wallet_standard_support.md b/Dev_Docs/Pending_Features/2026-06-22_0135_wallet_standard_support.md deleted file mode 100644 index 3f0e2b9..0000000 --- a/Dev_Docs/Pending_Features/2026-06-22_0135_wallet_standard_support.md +++ /dev/null @@ -1,18 +0,0 @@ -# Wallet Standard support - -- Краткое описание: - расширение `SHiNE Wallet` теперь не только внедряет legacy `window.solana`, но и регистрирует себя как `Wallet Standard` wallet для Solana dapp. - -- Что проверять: - 1. Перезагрузить unpacked extension. - 2. Открыть dapp, который использует Wallet Standard, например `app.realms.today` на `devnet`. - 3. Открыть список кошельков и убедиться, что `SHiNE Wallet` появился отдельным вариантом. - 4. Проверить `connect`. - 5. Проверить подпись транзакции через сценарий dapp, который использует стандартный wallet interface. - -- Ожидаемый результат: - - dapp видит `SHiNE Wallet` как standard wallet, а не только как legacy Phantom-style provider. - - connect и подпись работают через тот же ESP32 approval-flow. - -- Статус: - `pending` diff --git a/Dev_Docs/Pending_Features/2026-06-23_1245_esp32_homeserver_pda_update_fix.md b/Dev_Docs/Pending_Features/2026-06-23_1245_esp32_homeserver_pda_update_fix.md deleted file mode 100644 index 6da227a..0000000 --- a/Dev_Docs/Pending_Features/2026-06-23_1245_esp32_homeserver_pda_update_fix.md +++ /dev/null @@ -1,26 +0,0 @@ -## Кратко - -Исправлена ESP32-ветка обновления `user_pda` для добавления `homeserver`-сессии после миграции формата PDA на `RecoveryKeyBlock`. - -## Что сделано - -- В `shine_homeserver_main.ino` синхронизирован `create/update` payload с новым форматом `shine_users`. -- В сериализацию и парсинг PDA добавлен `RecoveryKeyBlock`. -- Для ветки `Add Homeserver` добавлены промежуточные checkpoint-записи в NVS, чтобы после crash или reset было видно, на каком шаге оборвалась операция. -- В `ESP32/AGENTS.md` добавлена памятка по чтению `last_error`. - -## Что проверять - -- Зарегистрировать или использовать уже существующий аккаунт на ESP32. -- Дойти до состояния `homeserver not in PDA`. -- Нажать `Add Homeserver`. -- Если операция не успешна, считать `last_error` по USB serial и убедиться, что видна свежая запись именно по шагам `Homeserver PDA update ...`, а не старый diag. - -## Ожидаемый результат - -- `Add Homeserver` добавляет `homeserver1` в `sessions` блока `SessionsBlock`. -- Если операция падает, в NVS сохраняется свежая диагностическая запись с текущим этапом, а не устаревший лог регистрации. - -## Статус - -`pending` diff --git a/Dev_Docs/Personal_Messages/TODO_доработка_персональных_сообщений_для_агентов.md b/Dev_Docs/Personal_Messages/TODO_доработка_персональных_сообщений_для_агентов.md deleted file mode 100644 index 20b27cf..0000000 --- a/Dev_Docs/Personal_Messages/TODO_доработка_персональных_сообщений_для_агентов.md +++ /dev/null @@ -1,42 +0,0 @@ -# TODO: доработка персональных сообщений для агентов - -Статус: отложено. - -## Что хотели сделать - -Добавить упрощённую маршрутизацию персональных сообщений через служебную инструкцию в начале текстового payload (внутри подписанного DM-блока), чтобы: - -- отличать сообщения человеку от сообщений агенту; -- отличать сообщения от человека и от агента; -- скрывать в обычном UI сообщения, адресованные агенту (`target=agent`); -- поддержать сценарий «сообщения самому себе между своими клиентами/устройствами», где один клиент/агент пишет другому в рамках одного логина. - -## Базовая идея формата (черновик) - -Пример префикса: - -```text -@shine:pm:v1 {"target":"agent","agentId":"assistant","author":"human"} -Текст сообщения... -``` - -Пример ответа агента: - -```text -@shine:pm:v1 {"target":"user","author":"agent","agentId":"assistant","agentLabel":"My Bot"} -Ответ агента... -``` - -## Почему отложено - -- нужно отдельно согласовать финальный формат инструкции; -- нужно определить строгие правила UI-фильтрации и fallback; -- нужно определить, нужен ли позднее отдельный серверный роутинг для agent-сессий. - -## Что сделать при возвращении к задаче - -1. Зафиксировать окончательный формат префикса и JSON-полей. -2. Описать правила парсинга/валидации (включая битые/неполные префиксы). -3. Добавить UI-логику показа/скрытия agent-сообщений. -4. Добавить маркировку «ответ агента» в диалоге. -5. Продумать режим self-chat (между своими клиентами/агентом) в рамках одного логина. diff --git a/Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md b/Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md deleted file mode 100644 index f339c67..0000000 --- a/Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md +++ /dev/null @@ -1,73 +0,0 @@ -# Черновик будущих вложений в DM - -## Важно - -Этот документ описывает только ранний черновик идеи. - -Сейчас в проекте **нет** поддержки вложений в личных сообщениях: - -- в реализованном формате `SHiNE_DM` поле `attachmentsCount` пока всегда должно быть `0`; -- UI не показывает кнопку прикрепления файлов; -- сервер не принимает upload файлов для DM; -- сервер не раздаёт специальные DM-файлы по отдельным endpoints; -- сервер не хранит отдельные файловые связи для личных сообщений. - -Этот документ нужен только для того, чтобы рядом с актуальной документацией было явно видно: - -- какие идеи обсуждались; -- что это **не реализовано**; -- что формат, хранение и способ загрузки потом могут сильно измениться. - -## Что обсуждалось - -Рассматривался такой общий подход: - -- у контентного DM есть внешний список вложений; -- во внешнем формате лежат только технические данные; -- человекочитаемые данные о файле живут внутри зашифрованного тела сообщения; -- один и тот же blob-файл теоретически мог бы переиспользоваться в нескольких сообщениях. - -Черновой вариант внешнего списка: - -- `attachmentsCount` -- далее для каждого вложения: - - `encFileHashSHA256` (`32 bytes`) - - `encFileSize` (`u64`) - -Черновой вариант внутреннего маркера в тексте: - -```text -<> -``` - -Где обсуждались поля: - -- `type` -- `fileName` -- `origSize` -- `origHashB64u` -- `encHashB64u` -- `encSize` -- `keyB64u` -- `nonceB64u` - -## Что может измениться - -В будущем могут измениться любые части идеи: - -- сам бинарный формат; -- способ привязки файлов к сообщению; -- момент загрузки файла относительно отправки сообщения; -- серверное хранение blob-файлов; -- права доступа к скачиванию; -- способ рендера вложения в UI. - -Именно поэтому этот файл не надо воспринимать как актуальную спецификацию. - -## Источник истины на сейчас - -Актуальное состояние личных сообщений описано только в: - -- `Dev_Docs/Personal_Messages/README.md` - -Если между этим черновиком и основным README есть расхождение, верным считается `README.md`. diff --git a/Dev_Docs/Инициализация_Solana_регистрации/README.md b/Dev_Docs/Инициализация_Solana_регистрации/README.md deleted file mode 100644 index b9457b2..0000000 --- a/Dev_Docs/Инициализация_Solana_регистрации/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# Деплой и инициализация Solana-регистрации (две обязательные программы) - -## Коротко - -Для рабочей регистрации пользователя нужны **обе** программы: - -1. `shine_users` — хранение и обновление `user_pda`, economy-конфиг, логика регистрации. -2. `shine_login_guard` — проверка/классификация логина (CPI из `shine_users`). - -Если задеплоена только одна из них — регистрация неработоспособна. - -## Актуальные адреса (devnet) - -- `shine_users` (регистрация пользователей): - `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` -- `shine_login_guard`: - `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` -- `shine_payments`: - `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` - -## Подтверждение деплоя - -- Сеть: `https://api.devnet.solana.com` -- `shine_users`: - - `Program ID`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` - - TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM` -- `shine_login_guard`: - - `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` - - TX deploy: `5iptngPYrLLjPE3Xby24zyNW3edVUnBNLBx785vjojMoq5JNLFNQvLNAm3jNYHbpf2B36qtbpTNzcvUNyRDqm1Mf` - -## Порядок деплоя (devnet) - -1. Убедиться, что CLI смотрит в devnet и у кошелька есть SOL. -2. Собрать и задеплоить `shine_login_guard`. -3. Собрать и задеплоить `shine_users`. -4. Проверить, что адреса совпадают между: - - `Anchor.toml` - - `declare_id!` в `programs/*/src/lib.rs` - - UI/серверными константами. -5. Выполнить `init_users_economy_config` (один раз на программу `shine_users`). - -Пример команд: - -```bash -cd shine-solana/shine -solana config get -solana balance - -anchor build -p shine_login_guard -anchor deploy -p shine_login_guard - -anchor build -p shine_users -anchor deploy -p shine_users -``` - -## Куда вписаны адреса в проекте - -### UI - -- Общие Solana-константы: - - `shine-UI/js/solana-programs.js` -- Страница инициализации: - - `shine-UI/js/pages/solana-users-init-view.js` -- Переход на страницу: - - `shine-UI/js/pages/developer-settings-view.js` - -### Browser plugin wallet - -- Резолвер PDA и проверка адресов: - - `SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js` -- Проверка текущего wallet по PDA: - - `SHiNE-browser-plugin-wallet/background.js` -- Отображение состояния в popup: - - `SHiNE-browser-plugin-wallet/popup.js` - -### Сервер - -- Серверные константы Solana: - - `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java` - -### Solana / Anchor - -- `shine-solana/shine/Anchor.toml` -- `shine-solana/shine/programs/shine_users/src/lib.rs` (`declare_id!`) -- `shine-solana/shine/programs/shine_login_guard/src/lib.rs` (`declare_id!`) -- `shine-solana/shine/programs/shine_payments/src/lib.rs` (`declare_id!`) - -### Где ещё нужно синхронизировать адреса после нового deploy - -- UI-константы и все потребители в `shine-UI/js/*` -- browser-plugin-wallet -- серверный `SolanaProgramsConfig.java` -- Anchor-конфиг и `declare_id!` в Solana-модуле -- документы: - - `Dev_Docs/Solana/user_pda/README.md` - - `shine-solana/shine/doc/programs/shine_users.md` - - `shine-solana/shine/doc/devnet_keys_and_deploy.md` - -## Как запустить инициализацию economy PDA - -1. Открыть UI. -2. Перейти: `Профиль -> Настройки -> Настройки разработчика -> Solana: init регистрации`. -3. Подключить кошелёк (Phantom, devnet). -4. Нажать `Запустить init_users_economy_config`. -5. Дождаться статуса `Успешно`. - -Страница сама вычисляет PDA `users_economy_config` по seed: - -- seed: `shine_users_economy_config` -- program: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` - -## Кто оплачивает create/update user_pda - -- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`. -- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера. -- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию. -- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`. - -## Важно - -- `init_users_economy_config` выполняется один раз на программу. - Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение). -- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `clientKey`, а содержимое записи подписывает `rootKey`. -- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. - Несовпадение адреса приведёт к ошибке регистрации. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino index 0d7482a..09a3d43 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino @@ -70,10 +70,10 @@ int ge25519_frombytes(ge25519_p3 *h, const unsigned char *s); #define TEXT_EDIT_PANEL_H 330 #define TEST_VERSION "SHiNE homeserver (v.0.18)" -static const char *kShineUsersProgramId = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ"; +static const char *kShineUsersProgramId = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6"; static const char *kShineUsersUserPdaSeedPrefix = "user_login="; -static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; -static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; +static const char *kShineLoginGuardProgramId = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh"; +static const char *kShinePaymentsProgramId = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v"; static const char *kSystemProgramId = "11111111111111111111111111111111"; static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111"; static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111"; @@ -285,6 +285,17 @@ struct ActiveWalletSignRequest { Screen returnScreen = SCREEN_HOME; }; +struct PendingRemoteAddBlockRequest { + String fromLogin; + String fromSessionId; + String signalRequestId; + String signalType; + String data; + String sessionSignatureB64; + String clientSignatureB64; + uint64_t timeMs = 0; +}; + static lv_disp_draw_buf_t gDrawBuf; static lv_color_t *gBuf1 = nullptr; static lv_color_t *gBuf2 = nullptr; @@ -324,7 +335,7 @@ static String gScanResults[MAX_SCAN_RESULTS]; static int gScanResultCount = 0; static WifiViewMode gWifiViewMode = WIFI_VIEW_OVERVIEW; -static String gSolanaRpcUrl = "https://api.devnet.solana.com"; +static String gSolanaRpcUrl = "https://api.mainnet-beta.solana.com"; static String gShineServerLogin = kDefaultShineServerLogin; static String gShineServerUrl; static String gResolvedShineServerLogin; @@ -415,6 +426,7 @@ static String gCustomWalletName; static String gCustomWalletPubB58; static String gCustomWalletPrivB58; static std::vector gPendingWalletRpcRequests; +static std::vector gPendingRemoteAddBlockRequests; static const int kWalletRpcSignalTypeRequest = 9100; static const int kWalletRpcSignalTypeResponse = 9101; static ActiveWalletSignRequest gActiveWalletSignRequest; @@ -477,7 +489,9 @@ static void refreshSelectedWalletBalanceState(); static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut); static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut); static bool queueIncomingWalletRpcRequest(const String &frame); +static bool queueIncomingRemoteAddBlockRequest(const String &frame); static void processPendingWalletRpcRequests(); +static void processPendingRemoteAddBlockRequests(); static void pumpShineIncomingFrames(uint32_t maxFrames = 3); static String loginDisplayValue(); static String homeserverDisplayValue(); @@ -496,11 +510,13 @@ static void saveShineSessionPrefs(); static String normalizeLoginValue(const String &value); static bool base58ToFixed32(const String &value, uint8_t out[32]); static bool base64DecodeStd(const String &value, std::vector &out); +static bool hex64ToBytes(const String &value, uint8_t out[32]); static String bytesToBase64String(const uint8_t *data, size_t len); static String jsonEscape(const String &value); static bool jsonStringField(const String &json, const String &field, String &valueOut); static bool jsonBoolField(const String &json, const String &field, bool &valueOut); static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut); +static bool jsonSignedInt64Field(const String &json, const String &field, int64_t &valueOut); static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut); static String formatPairingShortCode(const String &value); static bool pairingMenuVisible(); @@ -617,6 +633,22 @@ static bool sendWalletRpcResponse(const String &toLogin, const String &targetSessionId, const String &callId, const String &responseData); +static bool sendSignalResponse(const String &toLogin, + const String &targetSessionId, + const String &signalType, + const String &signalRequestId, + const String &responseData); +static bool buildSendSignalSignatures(const String &toLogin, + const String &targetMode, + const String &targetSessionId, + const String &signalType, + const String &signalRequestId, + const String &data, + uint64_t timeMs, + bool includeClientSignature, + String &sessionSignatureB64Out, + String &clientSignatureB64Out, + String &errorOut); static void queueWalletSignRequest(const PendingWalletRpcRequest &item, const String &requestId, const String &publicKeyBase58, @@ -881,6 +913,14 @@ static String bytesToBase64String(const uint8_t *data, size_t len) { return base64Std(data, len); } +static String trimBase64Padding(const String &value) { + String out = value; + while (out.endsWith("=")) { + out.remove(out.length() - 1); + } + return out; +} + static String bytesToHexString(const uint8_t *data, size_t len) { static const char *kHex = "0123456789abcdef"; String out; @@ -900,6 +940,30 @@ static String normalizeLoginValue(const String &value) { return out; } +static int hexNibbleValue(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return -1; +} + +static bool hex64ToBytes(const String &value, uint8_t out[32]) { + String clean = value; + clean.trim(); + if (clean.length() != 64) { + return false; + } + for (size_t i = 0; i < 32; ++i) { + int hi = hexNibbleValue(clean.charAt((int)(i * 2))); + int lo = hexNibbleValue(clean.charAt((int)(i * 2 + 1))); + if (hi < 0 || lo < 0) { + return false; + } + out[i] = (uint8_t)((hi << 4) | lo); + } + return true; +} + static bool isValidShineServerLoginValue(const String &value) { if (value.isEmpty() || value.length() > 20) { return false; @@ -1587,6 +1651,34 @@ static bool jsonInt64Field(const String &json, const String &field, uint64_t &va return true; } +static bool jsonSignedInt64Field(const String &json, const String &field, int64_t &valueOut) { + String needle = "\"" + field + "\""; + int keyPos = json.indexOf(needle); + if (keyPos < 0) { + return false; + } + int colon = json.indexOf(':', keyPos + needle.length()); + if (colon < 0) { + return false; + } + int pos = colon + 1; + while (pos < (int)json.length() && (json[pos] == ' ' || json[pos] == '\n' || json[pos] == '\r' || json[pos] == '\t')) { + pos++; + } + int start = pos; + if (pos < (int)json.length() && json[pos] == '-') { + pos++; + } + while (pos < (int)json.length() && isDigit((unsigned char)json[pos])) { + pos++; + } + if (pos == start || (pos == start + 1 && json[start] == '-')) { + return false; + } + valueOut = strtoll(json.substring(start, pos).c_str(), nullptr, 10); + return true; +} + static String formatSolValue(uint64_t lamports) { uint64_t whole = lamports / 1000000000ULL; uint64_t frac = (lamports % 1000000000ULL) / 1000000ULL; @@ -2695,6 +2787,195 @@ static bool sendWalletRpcResponse(const String &toLogin, return shineWsRequest(gShineWs, "CallSignalToSession", req, response, SHINE_RPC_TIMEOUT_MS); } +static bool buildSendSignalSignatures(const String &toLogin, + const String &targetMode, + const String &targetSessionId, + const String &signalType, + const String &signalRequestId, + const String &data, + uint64_t timeMs, + bool includeClientSignature, + String &sessionSignatureB64Out, + String &clientSignatureB64Out, + String &errorOut) { + sessionSignatureB64Out = ""; + clientSignatureB64Out = ""; + errorOut = ""; + + if (gLoginValue.isEmpty() || gShineSessionId.isEmpty()) { + errorOut = "session_context_missing"; + return false; + } + + uint8_t dataHash32[32] = {}; + sha256calc(reinterpret_cast(data.c_str()), data.length(), dataHash32); + String dataSha256B64 = trimBase64Padding(bytesToBase64String(dataHash32, sizeof(dataHash32))); + + uint8_t subSeed[32] = {}; + uint8_t subPub[32] = {}; + uint8_t subSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) { + errorOut = "homeserver_session_key_unavailable"; + return false; + } + + String sessionPreimage = String("SEND_SIGNAL_SESSION:") + + gLoginValue + ":" + + gShineSessionId + ":" + + toLogin + ":" + + targetMode + ":" + + targetSessionId + ":" + + signalType + ":" + + signalRequestId + ":" + + String((unsigned long long)timeMs) + ":" + + dataSha256B64; + uint8_t sessionSignature[64] = {}; + std::vector sessionMessage(reinterpret_cast(sessionPreimage.c_str()), + reinterpret_cast(sessionPreimage.c_str()) + sessionPreimage.length()); + if (!signMessageEd25519(sessionMessage, subSec, sessionSignature)) { + errorOut = "session_signal_sign_failed"; + return false; + } + sessionSignatureB64Out = bytesToBase64String(sessionSignature, sizeof(sessionSignature)); + + if (!includeClientSignature) { + return true; + } + + uint8_t clientSeed[32] = {}; + uint8_t clientPub[32] = {}; + uint8_t clientSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, clientSec)) { + errorOut = "client_key_unavailable"; + return false; + } + + String clientPreimage = String("SEND_SIGNAL_CLIENT:") + + gLoginValue + ":" + + gShineSessionId + ":" + + toLogin + ":" + + targetMode + ":" + + targetSessionId + ":" + + signalType + ":" + + signalRequestId + ":" + + String((unsigned long long)timeMs) + ":" + + dataSha256B64; + uint8_t clientSignature[64] = {}; + std::vector clientMessage(reinterpret_cast(clientPreimage.c_str()), + reinterpret_cast(clientPreimage.c_str()) + clientPreimage.length()); + if (!signMessageEd25519(clientMessage, clientSec, clientSignature)) { + errorOut = "client_signal_sign_failed"; + return false; + } + clientSignatureB64Out = bytesToBase64String(clientSignature, sizeof(clientSignature)); + return true; +} + +static bool sendSignalResponse(const String &toLogin, + const String &targetSessionId, + const String &signalType, + const String &signalRequestId, + const String &responseData) { + String response; + uint64_t timeMs = shineNowMs(); + String sessionSignatureB64; + String clientSignatureB64; + String error; + if (!buildSendSignalSignatures(toLogin, + "single_session", + targetSessionId, + signalType, + signalRequestId, + responseData, + timeMs, + false, + sessionSignatureB64, + clientSignatureB64, + error)) { + return false; + } + + String req = String("{\"toLogin\":\"") + jsonEscape(toLogin) + + "\",\"targetMode\":\"single_session\"" + + ",\"targetSessionId\":\"" + jsonEscape(targetSessionId) + + "\",\"signalType\":\"" + jsonEscape(signalType) + + "\",\"signalRequestId\":\"" + jsonEscape(signalRequestId) + + "\",\"data\":\"" + jsonEscape(responseData) + + "\",\"timeMs\":" + String((unsigned long long)timeMs) + + ",\"sessionSignatureB64\":\"" + jsonEscape(sessionSignatureB64) + + "\",\"clientSignatureB64\":\"" + jsonEscape(clientSignatureB64) + "\"}"; + return shineWsRequest(gShineWs, "SendSignal", req, response, SHINE_RPC_TIMEOUT_MS); +} + +static void appendUint16BE(std::vector &out, uint16_t value) { + out.push_back((uint8_t)((value >> 8) & 0xFF)); + out.push_back((uint8_t)(value & 0xFF)); +} + +static void appendInt32BE(std::vector &out, int32_t value) { + uint32_t v = (uint32_t)value; + out.push_back((uint8_t)((v >> 24) & 0xFF)); + out.push_back((uint8_t)((v >> 16) & 0xFF)); + out.push_back((uint8_t)((v >> 8) & 0xFF)); + out.push_back((uint8_t)(v & 0xFF)); +} + +static void appendInt64BE(std::vector &out, int64_t value) { + uint64_t v = (uint64_t)value; + out.push_back((uint8_t)((v >> 56) & 0xFF)); + out.push_back((uint8_t)((v >> 48) & 0xFF)); + out.push_back((uint8_t)((v >> 40) & 0xFF)); + out.push_back((uint8_t)((v >> 32) & 0xFF)); + out.push_back((uint8_t)((v >> 24) & 0xFF)); + out.push_back((uint8_t)((v >> 16) & 0xFF)); + out.push_back((uint8_t)((v >> 8) & 0xFF)); + out.push_back((uint8_t)(v & 0xFF)); +} + +static uint16_t readUint16BE(const uint8_t *data) { + return (uint16_t)(((uint16_t)data[0] << 8) | (uint16_t)data[1]); +} + +static bool fetchRemoteAddBlockCursor(const String &login, + String &blockchainNameOut, + int32_t &lastBlockNumberOut, + String &lastBlockHashOut, + String &errorMessageOut, + String &errorCodeOut) { + const char *kRemoteZeroHash64 = "0000000000000000000000000000000000000000000000000000000000000000"; + blockchainNameOut = ""; + lastBlockNumberOut = -1; + lastBlockHashOut = String(kRemoteZeroHash64); + errorMessageOut = ""; + errorCodeOut = ""; + + String getUserReq = String("{\"login\":\"") + jsonEscape(login) + "\"}"; + String getUserResp; + bool ok = shineWsRequest(gShineWs, "GetUser", getUserReq, getUserResp, SHINE_RPC_TIMEOUT_MS); + uint64_t statusCode = 0; + jsonInt64Field(getUserResp, "status", statusCode); + if (!ok || statusCode != 200) { + jsonStringField(getUserResp, "message", errorMessageOut); + jsonStringField(getUserResp, "code", errorCodeOut); + if (errorCodeOut.isEmpty()) errorCodeOut = ok ? "getuser_rejected" : "getuser_request_failed"; + if (errorMessageOut.isEmpty()) errorMessageOut = ok ? "GetUser rejected by server" : "GetUser request failed"; + return false; + } + + int64_t lastBlockNumberI64 = -1; + jsonStringField(getUserResp, "blockchainName", blockchainNameOut); + jsonStringField(getUserResp, "serverLastGlobalHash", lastBlockHashOut); + if (!jsonSignedInt64Field(getUserResp, "serverLastGlobalNumber", lastBlockNumberI64)) { + lastBlockNumberI64 = -1; + } + lastBlockNumberOut = (int32_t)lastBlockNumberI64; + if (lastBlockHashOut.isEmpty()) { + lastBlockHashOut = String(kRemoteZeroHash64); + } + lastBlockHashOut.toLowerCase(); + return true; +} + static void queueWalletSignRequest(const PendingWalletRpcRequest &item, const String &requestId, const String &publicKeyBase58, @@ -4075,6 +4356,7 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St return true; } queueIncomingWalletRpcRequest(frame); + queueIncomingRemoteAddBlockRequest(frame); } return false; } @@ -4121,6 +4403,50 @@ static bool queueIncomingWalletRpcRequest(const String &frame) { return true; } +static bool queueIncomingRemoteAddBlockRequest(const String &frame) { + if (frame.indexOf("\"op\":\"IncomingSignal\"") < 0 || frame.indexOf("\"event\":true") < 0) { + return false; + } + int payloadPos = frame.indexOf("\"payload\":"); + if (payloadPos < 0) { + return false; + } + int objectStart = frame.indexOf('{', payloadPos); + if (objectStart < 0) { + return false; + } + int objectEnd = -1; + String payloadJson; + if (!extractJsonObjectAt(frame, objectStart, objectEnd, payloadJson)) { + return false; + } + + String signalType; + if (!jsonStringField(payloadJson, "signalType", signalType)) { + return false; + } + if (signalType != "remote_addblock_request") { + return false; + } + + PendingRemoteAddBlockRequest item; + jsonStringField(payloadJson, "fromLogin", item.fromLogin); + jsonStringField(payloadJson, "fromSessionId", item.fromSessionId); + jsonStringField(payloadJson, "signalRequestId", item.signalRequestId); + jsonStringField(payloadJson, "data", item.data); + jsonStringField(payloadJson, "sessionSignatureB64", item.sessionSignatureB64); + jsonStringField(payloadJson, "clientSignatureB64", item.clientSignatureB64); + uint64_t timeMs = 0; + jsonInt64Field(payloadJson, "timeMs", timeMs); + item.timeMs = timeMs; + item.signalType = signalType; + if (item.fromLogin.isEmpty() || item.fromSessionId.isEmpty() || item.signalRequestId.isEmpty()) { + return false; + } + gPendingRemoteAddBlockRequests.push_back(item); + return true; +} + static void processPendingWalletRpcRequests() { if (gPendingWalletRpcRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { return; @@ -4180,6 +4506,158 @@ static void processPendingWalletRpcRequests() { } } +static void processPendingRemoteAddBlockRequests() { + if (gPendingRemoteAddBlockRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { + return; + } + + while (!gPendingRemoteAddBlockRequests.empty()) { + PendingRemoteAddBlockRequest item = gPendingRemoteAddBlockRequests.front(); + gPendingRemoteAddBlockRequests.erase(gPendingRemoteAddBlockRequests.begin()); + + String responseData; + String blockchainName; + String blockBodyB64; + + if (item.fromLogin != gLoginValue) { + responseData = String("{\"ok\":false,\"error\":\"forbidden_login\",\"errorMessage\":\"Signal login mismatch\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + if (item.sessionSignatureB64.isEmpty() || item.clientSignatureB64.isEmpty()) { + responseData = String("{\"ok\":false,\"error\":\"missing_required_signatures\",\"errorMessage\":\"Client and session signatures are required\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + jsonStringField(item.data, "blockchainName", blockchainName); + jsonStringField(item.data, "blockBodyB64", blockBodyB64); + if (blockchainName.isEmpty() || blockBodyB64.isEmpty()) { + responseData = String("{\"ok\":false,\"error\":\"bad_request\",\"errorMessage\":\"Missing required AddBlock fields\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + std::vector remoteBody; + if (!base64DecodeStd(blockBodyB64, remoteBody) || remoteBody.size() < 6) { + responseData = String("{\"ok\":false,\"error\":\"bad_block_body_base64\",\"errorMessage\":\"Invalid remote block body base64\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + uint16_t msgType = readUint16BE(remoteBody.data()); + uint16_t msgSubType = readUint16BE(remoteBody.data() + 2); + uint16_t msgVersion = readUint16BE(remoteBody.data() + 4); + std::vector bodyBytes(remoteBody.begin() + 6, remoteBody.end()); + + String resolvedBlockchainName; + int32_t lastBlockNumber = -1; + String prevBlockHash; + String cursorError; + String cursorErrorCode; + if (!fetchRemoteAddBlockCursor(gLoginValue, resolvedBlockchainName, lastBlockNumber, prevBlockHash, cursorError, cursorErrorCode)) { + responseData = String("{\"ok\":false,\"error\":\"") + jsonEscape(cursorErrorCode) + + "\",\"errorMessage\":\"" + jsonEscape(cursorError) + + "\",\"requestId\":\"" + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + if (resolvedBlockchainName.isEmpty()) { + resolvedBlockchainName = blockchainName; + } + int32_t nextBlockNumber = lastBlockNumber + 1; + String cleanPrevHash = prevBlockHash.isEmpty() + ? String("0000000000000000000000000000000000000000000000000000000000000000") + : prevBlockHash; + uint8_t prevHash32[32] = {}; + if (!hex64ToBytes(cleanPrevHash, prevHash32)) { + responseData = String("{\"ok\":false,\"error\":\"bad_prev_hash\",\"errorMessage\":\"Invalid previous block hash from GetUser\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + std::vector preimage; + preimage.reserve(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.size()); + appendUint16BE(preimage, 0); + preimage.insert(preimage.end(), prevHash32, prevHash32 + 32); + int32_t blockSize = (int32_t)(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.size()); + appendInt32BE(preimage, blockSize); + appendInt32BE(preimage, nextBlockNumber); + appendInt64BE(preimage, (int64_t)(shineNowMs() / 1000ULL)); + appendUint16BE(preimage, msgType); + appendUint16BE(preimage, msgSubType); + appendUint16BE(preimage, msgVersion); + preimage.insert(preimage.end(), bodyBytes.begin(), bodyBytes.end()); + + uint8_t blockchainSeed[32] = {}; + uint8_t blockchainPub[32] = {}; + uint8_t blockchainSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec)) { + responseData = String("{\"ok\":false,\"error\":\"blockchain_key_unavailable\",\"errorMessage\":\"Blockchain key is unavailable on homeserver\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + uint8_t hash32[32] = {}; + sha256calc(preimage.data(), preimage.size(), hash32); + uint8_t signature[64] = {}; + if (!signMessageEd25519(std::vector(hash32, hash32 + 32), blockchainSec, signature)) { + responseData = String("{\"ok\":false,\"error\":\"sign_failed\",\"errorMessage\":\"Failed to sign AddBlock hash\",\"requestId\":\"") + + jsonEscape(item.signalRequestId) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + std::vector fullBlock = preimage; + fullBlock.push_back(0x01); + fullBlock.push_back(0x00); + fullBlock.insert(fullBlock.end(), signature, signature + 64); + String addBlockReq = String("{\"blockchainName\":\"") + jsonEscape(resolvedBlockchainName) + + "\",\"blockNumber\":" + String((long long)nextBlockNumber) + + ",\"prevBlockHash\":\"" + jsonEscape(cleanPrevHash) + + "\",\"blockBytesB64\":\"" + jsonEscape(bytesToBase64String(fullBlock.data(), fullBlock.size())) + "\"}"; + String addBlockResp; + bool addBlockOk = shineWsRequest(gShineWs, "AddBlock", addBlockReq, addBlockResp, SHINE_RPC_TIMEOUT_MS); + uint64_t statusCode = 0; + jsonInt64Field(addBlockResp, "status", statusCode); + String serverLastHash; + String errorCode; + String errorMessage; + uint64_t serverLastNumber = 0; + jsonStringField(addBlockResp, "serverLastGlobalHash", serverLastHash); + jsonStringField(addBlockResp, "code", errorCode); + jsonStringField(addBlockResp, "message", errorMessage); + jsonInt64Field(addBlockResp, "serverLastGlobalNumber", serverLastNumber); + + if (!addBlockOk || statusCode != 200) { + if (errorCode.isEmpty()) { + errorCode = addBlockOk ? "addblock_rejected" : "addblock_request_failed"; + } + if (errorMessage.isEmpty()) { + errorMessage = addBlockOk ? "AddBlock rejected by server" : "AddBlock request failed"; + } + responseData = String("{\"ok\":false,\"error\":\"") + jsonEscape(errorCode) + + "\",\"errorMessage\":\"" + jsonEscape(errorMessage) + + "\",\"requestId\":\"" + jsonEscape(item.signalRequestId) + + "\",\"serverLastGlobalNumber\":" + String((unsigned long long)serverLastNumber) + + ",\"serverLastGlobalHash\":\"" + jsonEscape(serverLastHash) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + continue; + } + + responseData = String("{\"ok\":true,\"requestId\":\"") + jsonEscape(item.signalRequestId) + + "\",\"serverLastGlobalNumber\":" + String((unsigned long long)serverLastNumber) + + ",\"serverLastGlobalHash\":\"" + jsonEscape(serverLastHash) + "\"}"; + sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData); + } +} + static void pumpShineIncomingFrames(uint32_t maxFrames) { if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { return; @@ -4193,6 +4671,7 @@ static void pumpShineIncomingFrames(uint32_t maxFrames) { break; } queueIncomingWalletRpcRequest(frame); + queueIncomingRemoteAddBlockRequest(frame); } } @@ -4748,6 +5227,7 @@ static void manageShineConnection() { pumpShineIncomingFrames(); processPendingWalletRpcRequests(); + processPendingRemoteAddBlockRequests(); } static void upsertKnownWifi(const String &ssid, const String &password) { @@ -4791,7 +5271,7 @@ static void loadPrefs() { if (!gWifiSavedSsid.isEmpty() && findKnownWifiIndex(gWifiSavedSsid) < 0) { upsertKnownWifi(gWifiSavedSsid, gWifiSavedPassword); } - gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com"); + gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.mainnet-beta.solana.com"); String storedShineServerLogin = normalizeLoginValue(gPrefs.getString("shine_server_login", "")); if (!isValidShineServerLoginValue(storedShineServerLogin)) { String legacyShineServer = normalizeLoginValue(gPrefs.getString("shine_server", "")); @@ -5603,6 +6083,13 @@ static void editorKeyCb(lv_event_t *event) { rebuildScreen(); return; } + if (strcmp(token, "") == 0) { + keepCursorAtEnd(); + lv_textarea_add_text(gInputTextArea, " "); + syncEditValueFromTextarea(); + keepCursorAtEnd(); + return; + } String out = token; if (gKeyboardMode == KEYBOARD_MODE_ALPHA && gKeyboardShift) { @@ -6773,10 +7260,10 @@ static void drawKeyRow(const char *const *tokens, lv_coord_t h, lv_coord_t gap, uint32_t bgColor, - bool uppercase = false, + bool uppercase = false, const lv_font_t *font = &lv_font_montserrat_18) { for (int i = 0; i < count; ++i) { - String label = tokens[i]; + String label = strcmp(tokens[i], "") == 0 ? String("SPACE") : String(tokens[i]); if (uppercase) { label.toUpperCase(); } @@ -6796,8 +7283,8 @@ static void drawTextEditorKeyboard(lv_coord_t originY) { static const char *symLeftRow1[] = {"://", "/", ":", "_", "@"}; static const char *symLeftRow2[] = {".com", ".net", ".org", ".me", ".io"}; static const char *symRightRow0[] = {"6", "7", "8", "9", "0"}; - static const char *symRightRow1[] = {"?", "&", "=", "+", "-"}; - static const char *symRightRow2[] = {"%", "#", "~", ":", "/"}; + static const char *symRightRow1[] = {"?", "!", "=", "+", "-"}; + static const char *symRightRow2[] = {"", "#", "~", ":", "/"}; const lv_coord_t keyH = 62; const lv_coord_t keyW5 = 84; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino index edf47cc..fc9e699 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_ui/shine_homeserver_ui.ino @@ -239,9 +239,9 @@ static DerivedKeyState gDerivedKeys = {}; static const char *kSystemProgramId = "11111111111111111111111111111111"; static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111"; static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111"; -static const char *kShineUsersProgramId = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ"; -static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; -static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; +static const char *kShineUsersProgramId = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6"; +static const char *kShineLoginGuardProgramId = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh"; +static const char *kShinePaymentsProgramId = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v"; static const char *kUsersSeedPrefix = "user_login="; static const char *kUsersEconomyConfigSeed = "shine_users_economy_config"; static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault"; @@ -1314,7 +1314,7 @@ static String defaultApiUrl() { } static String defaultRpcUrl() { - return "https://api.devnet.solana.com"; + return "https://api.mainnet-beta.solana.com"; } static String defaultWsUrl() { diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md index a523ea6..8ee0811 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_homeserver_ui_nav_minimal_spec.md @@ -154,7 +154,7 @@ - кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован. Значения по умолчанию: -- Solana RPC: `https://api.devnet.solana.com` +- Solana RPC: `https://api.mainnet-beta.solana.com` - SHiNE server login: `shineupme` Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`. @@ -259,6 +259,7 @@ - переключение страниц выполняется свайпом влево/вправо внутри `TEXT_EDIT_SCREEN`; - страница 1 содержит в основном буквы и базовые URL-символы; - страница 2 содержит цифры и дополнительные URL/символьные кнопки; +- на символьных страницах должны быть доступны как минимум `!` и отдельная кнопка `SPACE`; - переключение `ABC/123` и `SHIFT` не должно очищать уже введённый текст; - переключение страниц клавиатуры свайпом тоже не должно очищать уже введённый текст, включая цифры, символы и уже набранные заглавные буквы; - есть специальные действия `DEL` и `CLR`. @@ -308,6 +309,7 @@ - половина букв находится на левой странице, половина на правой; - на правой странице кнопки тоже стоят в ровных колонках, без сдвига рядов вправо; - отдельный режим `symbols` тоже разделён на 2 страницы; +- в режиме `symbols` на правой странице есть отдельная кнопка `SPACE` и отдельная кнопка `!`; - 3 основных ряда клавиш и нижний служебный ряд увеличены по высоте; - клавиши дополнительно увеличены по высоте по сравнению с предыдущим промежуточным вариантом; - четвёртый ряд содержит: diff --git a/Players/malvviiina/shine-staging-screenshots/channels-list.png b/Players/malvviiina/shine-staging-screenshots/channels-list.png new file mode 100644 index 0000000..3f33dcf Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/channels-list.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/chat-view_u1.png b/Players/malvviiina/shine-staging-screenshots/chat-view_u1.png new file mode 100644 index 0000000..698a188 Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/chat-view_u1.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/contact-search-view.png b/Players/malvviiina/shine-staging-screenshots/contact-search-view.png new file mode 100644 index 0000000..afea30b Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/contact-search-view.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/login.png b/Players/malvviiina/shine-staging-screenshots/login.png new file mode 100644 index 0000000..411333f Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/login.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/messages-list.png b/Players/malvviiina/shine-staging-screenshots/messages-list.png new file mode 100644 index 0000000..91499d9 Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/messages-list.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/network-view.png b/Players/malvviiina/shine-staging-screenshots/network-view.png new file mode 100644 index 0000000..b0a1ff7 Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/network-view.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/notifications-view.png b/Players/malvviiina/shine-staging-screenshots/notifications-view.png new file mode 100644 index 0000000..91499d9 Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/notifications-view.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/register.png b/Players/malvviiina/shine-staging-screenshots/register.png new file mode 100644 index 0000000..5dcdb8c Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/register.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/settings-view.png b/Players/malvviiina/shine-staging-screenshots/settings-view.png new file mode 100644 index 0000000..91499d9 Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/settings-view.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/start.png b/Players/malvviiina/shine-staging-screenshots/start.png new file mode 100644 index 0000000..ff94443 Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/start.png differ diff --git a/Players/malvviiina/shine-staging-screenshots/wallet-view.png b/Players/malvviiina/shine-staging-screenshots/wallet-view.png new file mode 100644 index 0000000..91499d9 Binary files /dev/null and b/Players/malvviiina/shine-staging-screenshots/wallet-view.png differ diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md index f9c2f20..a639404 100644 --- a/SHiNE-agent-bot-coder/AGENT.md +++ b/SHiNE-agent-bot-coder/AGENT.md @@ -48,13 +48,13 @@ - Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе. ## Планы и отложенные фичи -- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`. +- Планы проекта по отложенным фичам хранятся в `TODO/`. - Внутри есть три горизонта: - `near/` - ближайшие планы, обычно сегодня/завтра; - `medium/` - среднесрочные планы, обычно недели или 1-2 месяца; - `far/` - дальнее будущее без понятного срока. - Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам. -- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя. +- Файлы из `TODO/` не начинать реализовывать без явной команды пользователя. - После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`. ## Центр задач и предложений diff --git a/SHiNE-agent-bot-coder/AGENTS.md b/SHiNE-agent-bot-coder/AGENTS.md index 20aa593..b64d0f0 100644 --- a/SHiNE-agent-bot-coder/AGENTS.md +++ b/SHiNE-agent-bot-coder/AGENTS.md @@ -11,14 +11,14 @@ - При изменении логики сервиса сначала читать `AGENT.md`, затем код `py_bot_service.py`. ## Планы и задачи -- Отложенные задачи проекта лежат в `../Dev_Docs/Future_Features/`. -- Точка входа по планам: `../Dev_Docs/Future_Features/README.md`. +- Отложенные задачи проекта лежат в `../TODO/`. +- Точка входа по планам: `../TODO/README.md`. - Горизонты планов: - `near/` - ближайшие планы; - `medium/` - среднесрочные планы; - `far/` - дальнее будущее. - Если пользователь спрашивает, какие есть планы или что можно продолжить, кратко перечислять задачи по этим горизонтам. -- Не начинать реализацию задач из `Future_Features` без явной команды пользователя. +- Не начинать реализацию задач из `TODO` без явной команды пользователя. ## Проверка после изменений - Если меняется логика Telegram-бота, проверить локальный запуск или self-test, когда это уместно. diff --git a/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js b/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js index 284363f..b21aac6 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js +++ b/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js @@ -1,8 +1,8 @@ import { base64ToBytes } from './crypto-utils.js'; import { PublicKey } from './vendor/solana-publickey-bundle.js'; -const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com'; -const SHINE_USERS_PROGRAM_ID = '3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ'; +const SOLANA_ENDPOINT_DEFAULT = 'https://solana-rpc.publicnode.com'; +const SHINE_USERS_PROGRAM_ID = 'SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6'; const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login='; const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme'; const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me'; diff --git a/SHiNE-server/AGENTS.md b/SHiNE-server/AGENTS.md index dd8dd5d..5976df1 100644 --- a/SHiNE-server/AGENTS.md +++ b/SHiNE-server/AGENTS.md @@ -42,8 +42,8 @@ shine-UI/server-ui.html Для обновления — только root + device (blockchain-ключ не нужен). Актуальные адреса программ Solana (devnet): -- `shine_users`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` -- `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` +- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` +- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md` @@ -59,7 +59,7 @@ shine-UI/server-ui.html ./gradlew deployUI ``` -Default deploy по умолчанию идёт на `test2.shineup.me` (`player@193.8.215.70`). +Default deploy по умолчанию идёт на `t.shineup.me` (`player@193.8.215.70`). Production deploy: @@ -77,7 +77,7 @@ Production deploy: ./gradlew deployUITest ``` -`test.shineup.me` пока не использовать для обычного deploy. +`test.shineup.me` считается резервным тестовым сервером и в обычный deploy не включается. Логи на проде: - `/home/player/SHiNE/shine-server/logs/app.log` diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java b/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java index 9872c88..a7550fb 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java @@ -9,8 +9,10 @@ import java.util.Objects; * FileStoreUtil — утилита работы с файлами в папке data/. * * Теперь поддерживает: - * - основной файл блокчейна: .bch - * - временный файл блокчейна: .tmp_bch + * - основной файл блокчейна: .bch + * - временный файл блокчейна: .tmp_bch + * - sidecar-файл проверки записи: .write_check + * - marker-файл записи: .write_pending * * Важное: * - validateSimpleFileName() запрещает path traversal. @@ -29,6 +31,15 @@ public final class FileStoreUtil { /** Расширение временного файла (старое+новое). */ public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch"; + /** Маркер того, что chain сейчас в процессе полного resync. */ + public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending"; + + /** Marker того, что обычный AddBlock находится в опасной фазе записи. */ + public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending"; + + /** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */ + public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check"; + private static final FileStoreUtil INSTANCE = new FileStoreUtil(); private final Path dataDirPath; @@ -130,6 +141,87 @@ public final class FileStoreUtil { newFile(buildBlockchainTmpFileName(blockchainName), data); } + /** .write_check */ + public String buildBlockchainWriteCheckFileName(String blockchainName) { + validateSimpleFileName(blockchainName); + return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION; + } + + public Path resolveBlockchainWriteCheckPath(String blockchainName) { + return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName)); + } + + public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) { + StringBuilder sb = new StringBuilder(128); + sb.append("blockNumber=").append(blockNumber).append('\n'); + sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n'); + newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + /** .write_pending */ + public String buildBlockchainWritePendingMarkerFileName(String blockchainName) { + validateSimpleFileName(blockchainName); + return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION; + } + + public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) { + return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName)); + } + + public void writeBlockchainWritePendingMarker(String blockchainName) { + newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]); + } + + /** .resync_pending */ + public String buildBlockchainResyncMarkerFileName(String blockchainName) { + validateSimpleFileName(blockchainName); + return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION; + } + + public Path resolveBlockchainResyncMarkerPath(String blockchainName) { + return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName)); + } + + public void writeBlockchainResyncMarker(String blockchainName, String markerContent) { + byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8); + newFile(buildBlockchainResyncMarkerFileName(blockchainName), data); + } + + public void deleteIfExists(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new IllegalStateException("Не удалось удалить файл: " + path, e); + } + } + + public void deleteBlockchainFileIfExists(String blockchainName) { + deleteIfExists(resolveBlockchainPath(blockchainName)); + } + + public void deleteBlockchainTmpFileIfExists(String blockchainName) { + deleteIfExists(resolveBlockchainTmpPath(blockchainName)); + } + + public void deleteBlockchainWriteCheckIfExists(String blockchainName) { + deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName)); + } + + public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) { + deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName)); + } + + public void deleteBlockchainResyncMarkerIfExists(String blockchainName) { + deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName)); + } + + public boolean existsBlockchainResyncMarker(String blockchainName) { + return exists(buildBlockchainResyncMarkerFileName(blockchainName)); + } + /** * Атомарно заменить основной файл блокчейна временным: * .tmp_bch -> .bch @@ -202,4 +294,4 @@ public final class FileStoreUtil { throw new IllegalArgumentException("Недопустимое имя файла: " + fileName); } } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java index 00adf62..0d53fe4 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java @@ -3,6 +3,9 @@ package utils.config; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Properties; public final class AppConfig { @@ -26,18 +29,30 @@ public final class AppConfig { } private void load() { - try (InputStream in = getClass().getClassLoader() - .getResourceAsStream("application.properties")) { - + try (InputStream in = getClass().getClassLoader().getResourceAsStream("application.properties")) { if (in == null) { throw new RuntimeException("Config file application.properties not found"); } - properties.load(in); - } catch (IOException e) { throw new RuntimeException("Failed to load application.properties", e); } + + Path externalConfig = Paths.get("application.properties"); + if (!Files.isRegularFile(externalConfig)) { + return; + } + + Properties override = new Properties(); + try (InputStream in = Files.newInputStream(externalConfig)) { + override.load(in); + } catch (IOException e) { + throw new RuntimeException("Failed to load external application.properties from " + externalConfig.toAbsolutePath(), e); + } + + for (String name : override.stringPropertyNames()) { + properties.setProperty(name, override.getProperty(name)); + } } /** Вернёт значение строки или null, если параметр не найден */ diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java index e540c25..61149af 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java @@ -1,20 +1,31 @@ package utils.config; /** - * Публичные адреса Solana-программ SHiNE (жёстко зафиксированы для devnet). - * Секреты на сервере не хранятся. + * Публичные адреса Solana-программ SHiNE. + * Program ID одинаковые для mainnet/devnet, а cluster/RPC должны + * переопределяться через application.properties конкретного инстанса. */ public final class SolanaProgramsConfig { private SolanaProgramsConfig() {} - public static final String SOLANA_CLUSTER = "devnet"; - public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com"; + private static final AppConfig CONFIG = AppConfig.getInstance(); - // Программа регистрации пользователей (shine_users), задеплоена в devnet. - public static final String SHINE_USERS_PROGRAM_ID = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ"; + public static final String SOLANA_CLUSTER = getParam("solana.cluster", "mainnet-beta"); + public static final String SOLANA_RPC_URL = getParam("solana.rpcUrl", "https://api.mainnet-beta.solana.com"); + + // Программа регистрации пользователей (shine_users), задеплоена в mainnet. + public static final String SHINE_USERS_PROGRAM_ID = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6"; // Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково. - public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; - public static final String SHINE_PAYMENTS_PROGRAM_ID = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; + public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh"; + public static final String SHINE_PAYMENTS_PROGRAM_ID = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v"; + + private static String getParam(String key, String defaultValue) { + String value = CONFIG.getParam(key); + if (value == null || value.isBlank()) { + return defaultValue; + } + return value.trim(); + } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index a1d781e..81eeb4b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -622,7 +622,7 @@ public final class DatabaseInitializer { ON signed_direct_messages_history (to_login, created_at_ms); """); - // 13) signed_messages_v2 (универсальное хранилище блоков типов 1/2/3/4) + // 13) signed_messages_v2 (универсальное хранилище блоков типов 1..8) st.executeUpdate(""" CREATE TABLE IF NOT EXISTS signed_messages_v2 ( message_key TEXT NOT NULL PRIMARY KEY, @@ -634,6 +634,7 @@ public final class DatabaseInitializer { nonce INTEGER NOT NULL, message_type INTEGER NOT NULL, revision_time_ms INTEGER NOT NULL DEFAULT 0, + reencrypted_at_ms INTEGER NOT NULL DEFAULT 0, raw_block BLOB NOT NULL, created_at_ms INTEGER NOT NULL, source_api TEXT NOT NULL, diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 16c3635..6f39a7b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 8; + private static final int LATEST_SCHEMA_VERSION = 11; private final String jdbcUrl; @@ -91,6 +91,9 @@ public final class SqliteDbController { case 6 -> migrateToV6(); case 7 -> migrateToV7(); case 8 -> migrateToV8(); + case 9 -> migrateToV9(); + case 10 -> migrateToV10(); + case 11 -> migrateToV11(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -269,6 +272,63 @@ public final class SqliteDbController { } } + private void migrateToV9() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureSyncServersTable(st); + setSchemaVersion(c, 9); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v9 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v9 failed", e); + } + } + + private void migrateToV10() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + ensureSignedMessagesReencryptedColumn(c, st); + setSchemaVersion(c, 10); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v10 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v10 failed", e); + } + } + + private void migrateToV11() { + try (Connection c = DriverManager.getConnection(jdbcUrl); + Statement st = c.createStatement()) { + c.setAutoCommit(false); + try { + clearLegacySignedMessagesForDmV11(c, st); + setSchemaVersion(c, 11); + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + throw new RuntimeException("DB migration to v11 failed", e); + } finally { + try { c.setAutoCommit(true); } catch (Exception ignored) {} + } + } catch (SQLException e) { + throw new RuntimeException("DB migration to v11 failed", e); + } + } + private static void ensureChat200StateTables(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS chat200_state ( @@ -396,6 +456,28 @@ public final class SqliteDbController { } } + private static void ensureSignedMessagesReencryptedColumn(Connection c, Statement st) throws SQLException { + if (!tableExists(c, "signed_messages_v2")) return; + if (!columnExists(c, "signed_messages_v2", "reencrypted_at_ms")) { + st.executeUpdate("ALTER TABLE signed_messages_v2 ADD COLUMN reencrypted_at_ms INTEGER NOT NULL DEFAULT 0"); + } + } + + /** + * Временная одноразовая миграция на переходе к SHiNE_DM v1: + * старые строки signed_messages_v2 больше не гарантированно совместимы + * с новым бинарным форматом и правилами tombstone/reencrypt. + * Позже эту миграцию нужно убрать, чтобы случайно не очищать рабочие DM. + */ + private static void clearLegacySignedMessagesForDmV11(Connection c, Statement st) throws SQLException { + if (tableExists(c, "signed_message_session_delivery")) { + st.executeUpdate("DELETE FROM signed_message_session_delivery"); + } + if (tableExists(c, "signed_messages_v2")) { + st.executeUpdate("DELETE FROM signed_messages_v2"); + } + } + private static void dropDmFileTables(Statement st) throws SQLException { st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_login"); st.executeUpdate("DROP INDEX IF EXISTS idx_dm_message_file_links_message"); @@ -468,6 +550,20 @@ public final class SqliteDbController { """); } + private static void ensureSyncServersTable(Statement st) throws SQLException { + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS sync_servers ( + login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, + server_address TEXT NOT NULL DEFAULT '', + updated_at_ms INTEGER NOT NULL + ); + """); + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_sync_servers_updated + ON sync_servers (updated_at_ms); + """); + } + private static void createConnectionsStateTable(Statement st) throws SQLException { st.executeUpdate(""" diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java new file mode 100644 index 0000000..9fe04ae --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -0,0 +1,394 @@ +package shine.db.dao; + +import shine.db.DatabaseInitializer; +import shine.db.SqliteDbController; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * BlockchainResyncCleanupDAO — подготовительный "жёсткий reset" одной blockchain-цепочки + * перед её полной повторной загрузкой от сервера-партнёра. + * + * Что делает этот DAO: + * 1) в ОДНОЙ SQL-транзакции сначала аккуратно уменьшает чужие агрегаты, + * которые были увеличены блоками удаляемой цепочки: + * - likes_count + * - replies_count + * 2) затем удаляет все локальные записи самой цепочки и её производные состояния. + * + * Почему это вынесено в отдельный DAO-метод, а не в триггеры DELETE: + * - нам нужен один понятный "блок операции", который можно вызвать из resync-flow; + * - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам; + * - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии; + * - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию: + * SQLite не может атомарно закоммитить и SQL, и файловую систему сразу; + * поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом + * отдельным recovery/resync-слоем после успешного commit. + * + * Важный смысл текущей реализации: + * - мы НЕ трогаем identity-слой (`solana_users`) и НЕ трогаем DM-таблицы; + * - мы очищаем только блокчейн пользователя и derived-state, который строится из неё; + * - висячие cross-chain ссылки в чужих blocks допускаются как нормальное поведение системы. + */ +public final class BlockchainResyncCleanupDAO { + + private static final int BLOCKCHAIN_LOGIN_SUFFIX_LEN = 4; // "-001" + + private static volatile BlockchainResyncCleanupDAO instance; + + private final SqliteDbController db = SqliteDbController.getInstance(); + + private BlockchainResyncCleanupDAO() {} + + public static BlockchainResyncCleanupDAO getInstance() { + if (instance == null) { + synchronized (BlockchainResyncCleanupDAO.class) { + if (instance == null) instance = new BlockchainResyncCleanupDAO(); + } + } + return instance; + } + + /** + * Полностью очищает одну blockchain-цепочку и локальные derived-state, собранные из неё. + * + * Порядок внутри транзакции намеренно такой: + * 1. Сначала уменьшаем чужие likes_count для тех целей, где финальное состояние + * реакции этой цепочки было LIKE. + * 2. Сначала уменьшаем чужие replies_count для reply-блоков этой цепочки. + * 3. После этого удаляем локальные derived-state самой цепочки. + * 4. В конце удаляем blocks и blockchain_state. + * + * Это правильно потому, что агрегаты (`message_stats`) должны видеть исходные blocks + * и reactions_state на момент пересчёта. Если удалить blocks раньше, мы потеряем + * источник правды для корректного уменьшения счётчиков. + * + * Метод идемпотентен по смыслу: + * - если часть данных уже удалена раньше, повторный вызов просто удалит "0 строк"; + * - если blockchain_state уже отсутствует, login берём из blockchainName. + * + * Отдельно важно: + * - здесь НЕТ удаления .bch/.tmp_bch; + * - здесь НЕТ повторной загрузки цепочки; + * - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow. + */ + public CleanupResult cleanupBlockchainForFullResync(String blockchainName) throws SQLException { + if (blockchainName == null || blockchainName.isBlank()) { + throw new IllegalArgumentException("blockchainName is blank"); + } + + try (Connection c = db.getConnection()) { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + String login = resolveLoginForCleanup(c, blockchainName); + + int likesAdjusted = decreaseForeignLikesCount(c, blockchainName); + int repliesAdjusted = decreaseForeignRepliesCount(c, blockchainName); + + int deletedMessageStats = deleteMessageStatsForOwnTargets(c, blockchainName); + int deletedReactionsState = deleteReactionsStateForActorChain(c, blockchainName); + int deletedConnectionsState = deleteConnectionsStateForLogin(c, login); + int deletedUsersParams = deleteUsersParamsForLogin(c, login); + int deletedChannelNames = deleteChannelNamesForOwnerChain(c, blockchainName); + int deletedChat200State = deleteChat200StateForOwnerChain(c, blockchainName); + int deletedChat200Members = deleteChat200MembersForOwnerChain(c, blockchainName); + int deletedBlocks = deleteBlocksForChain(c, blockchainName); + int deletedBlockchainState = deleteBlockchainStateForChain(c, blockchainName); + + c.commit(); + + return new CleanupResult( + login, + likesAdjusted, + repliesAdjusted, + deletedMessageStats, + deletedReactionsState, + deletedConnectionsState, + deletedUsersParams, + deletedChannelNames, + deletedChat200State, + deletedChat200Members, + deletedBlocks, + deletedBlockchainState + ); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + if (e instanceof SQLException sqlEx) throw sqlEx; + throw new SQLException("Не удалось очистить blockchain для полного resync: " + blockchainName, e); + } finally { + try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {} + } + } + } + + private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException { + String sql = """ + SELECT login + FROM blockchain_state + WHERE blockchain_name = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, blockchainName); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + String login = rs.getString("login"); + if (login != null && !login.isBlank()) { + return login; + } + } + } + } + + String loginFromName = loginFromBlockchainName(blockchainName); + if (loginFromName == null || loginFromName.isBlank()) { + throw new IllegalArgumentException("Cannot derive login from blockchainName: " + blockchainName); + } + return loginFromName; + } + + /** + * DAO остаётся в модуле БД и не тянет зависимость на blockchain-utils модуль. + * Поэтому здесь локально повторяем минимальное правило имени chain: + * login + "-NNN". + */ + private String loginFromBlockchainName(String blockchainName) { + if (blockchainName == null) return null; + + String s = blockchainName.trim(); + if (s.length() <= BLOCKCHAIN_LOGIN_SUFFIX_LEN) return null; + + int dashPos = s.length() - BLOCKCHAIN_LOGIN_SUFFIX_LEN; + if (s.charAt(dashPos) != '-') return null; + + for (int i = dashPos + 1; i < s.length(); i++) { + char ch = s.charAt(i); + if (ch < '0' || ch > '9') return null; + } + return s.substring(0, dashPos); + } + + /** + * Уменьшаем likes_count только для ЧУЖИХ целей. + * + * Логика: + * - если у удаляемой цепочки финальное состояние реакции на цель = LIKE, + * значит при полном удалении цепочки этот активный лайк исчезает; + * - значит у message_stats этой чужой цели нужно сделать -1; + * - для целей внутри этой же chain этого делать не нужно, потому что сами цели + * тоже будут удалены вместе с цепочкой. + */ + private int decreaseForeignLikesCount(Connection c, String blockchainName) throws SQLException { + String sql = """ + UPDATE message_stats + SET likes_count = MAX( + 0, + likes_count - ( + SELECT COUNT(*) + FROM reactions_state rs + WHERE rs.from_bch_name = ? + AND rs.reaction_type = ? + AND rs.last_sub_type = ? + AND rs.to_login = message_stats.to_login + AND rs.to_bch_name = message_stats.to_bch_name + AND rs.to_block_number = message_stats.to_block_number + AND rs.to_block_hash = message_stats.to_block_hash + AND rs.to_bch_name <> ? + ) + ) + WHERE EXISTS ( + SELECT 1 + FROM reactions_state rs + WHERE rs.from_bch_name = ? + AND rs.reaction_type = ? + AND rs.last_sub_type = ? + AND rs.to_login = message_stats.to_login + AND rs.to_bch_name = message_stats.to_bch_name + AND rs.to_block_number = message_stats.to_block_number + AND rs.to_block_hash = message_stats.to_block_hash + AND rs.to_bch_name <> ? + ) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + ps.setString(i++, blockchainName); + ps.setInt(i++, DatabaseInitializer.REACTION_LIKE); + ps.setInt(i++, DatabaseInitializer.REACTION_LIKE); + ps.setString(i++, blockchainName); + ps.setString(i++, blockchainName); + ps.setInt(i++, DatabaseInitializer.REACTION_LIKE); + ps.setInt(i++, DatabaseInitializer.REACTION_LIKE); + ps.setString(i++, blockchainName); + return ps.executeUpdate(); + } + } + + /** + * Уменьшаем replies_count только для ЧУЖИХ целей. + * + * Если reply этой цепочки ссылался на сообщение из другой цепочки, + * значит после удаления blocks этой цепочки чужой replies_count должен уменьшиться. + * Reply на собственные сообщения здесь игнорируем: целевая цепочка тоже будет удалена. + */ + private int decreaseForeignRepliesCount(Connection c, String blockchainName) throws SQLException { + String sql = """ + UPDATE message_stats + SET replies_count = MAX( + 0, + replies_count - COALESCE(( + SELECT COUNT(*) + FROM blocks b + WHERE b.bch_name = ? + AND b.msg_type = 1 + AND b.msg_sub_type = ? + AND b.to_login = message_stats.to_login + AND b.to_bch_name = message_stats.to_bch_name + AND b.to_block_number = message_stats.to_block_number + AND b.to_block_hash = message_stats.to_block_hash + AND b.to_bch_name <> ? + ), 0) + ) + WHERE EXISTS ( + SELECT 1 + FROM blocks b + WHERE b.bch_name = ? + AND b.msg_type = 1 + AND b.msg_sub_type = ? + AND b.to_login = message_stats.to_login + AND b.to_bch_name = message_stats.to_bch_name + AND b.to_block_number = message_stats.to_block_number + AND b.to_block_hash = message_stats.to_block_hash + AND b.to_bch_name <> ? + ) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + ps.setString(i++, blockchainName); + ps.setInt(i++, DatabaseInitializer.TEXT_REPLY); + ps.setString(i++, blockchainName); + ps.setString(i++, blockchainName); + ps.setInt(i++, DatabaseInitializer.TEXT_REPLY); + ps.setString(i++, blockchainName); + return ps.executeUpdate(); + } + } + + /** + * Статистика сообщений самой удаляемой цепочки после reset не нужна, + * потому что её цели исчезают вместе с chain source data. + */ + private int deleteMessageStatsForOwnTargets(Connection c, String blockchainName) throws SQLException { + return executeDelete(c, """ + DELETE FROM message_stats + WHERE to_bch_name = ? + """, blockchainName); + } + + /** + * reactions_state хранит финальное состояние реакций АКТОРА. + * После удаления всей цепочки актор этой цепочки исчезает, поэтому + * достаточно удалить все строки по from_bch_name. + */ + private int deleteReactionsStateForActorChain(Connection c, String blockchainName) throws SQLException { + return executeDelete(c, """ + DELETE FROM reactions_state + WHERE from_bch_name = ? + """, blockchainName); + } + + /** + * connections_state — текущее состояние связей, выставленных этим login. + * Чистим по владельцу состояния. + */ + private int deleteConnectionsStateForLogin(Connection c, String login) throws SQLException { + return executeDelete(c, """ + DELETE FROM connections_state + WHERE login = ? COLLATE NOCASE + """, login); + } + + /** + * users_params — актуальные параметры, собранные из блоков пользователя. + */ + private int deleteUsersParamsForLogin(Connection c, String login) throws SQLException { + return executeDelete(c, """ + DELETE FROM users_params + WHERE login = ? COLLATE NOCASE + """, login); + } + + /** + * Каналы принадлежат owner_bch_name. + */ + private int deleteChannelNamesForOwnerChain(Connection c, String blockchainName) throws SQLException { + return executeDelete(c, """ + DELETE FROM channel_names_state + WHERE owner_bch_name = ? + """, blockchainName); + } + + private int deleteChat200StateForOwnerChain(Connection c, String blockchainName) throws SQLException { + return executeDelete(c, """ + DELETE FROM chat200_state + WHERE owner_bch_name = ? + """, blockchainName); + } + + private int deleteChat200MembersForOwnerChain(Connection c, String blockchainName) throws SQLException { + return executeDelete(c, """ + DELETE FROM chat200_members_state + WHERE owner_bch_name = ? + """, blockchainName); + } + + /** + * blocks удаляем в конце, потому что до этого шага они нужны как источник правды + * для уменьшения replies_count. + */ + private int deleteBlocksForChain(Connection c, String blockchainName) throws SQLException { + return executeDelete(c, """ + DELETE FROM blocks + WHERE bch_name = ? + """, blockchainName); + } + + /** + * blockchain_state удаляем после blocks, чтобы не нарушать FK-связь blocks -> blockchain_state. + */ + private int deleteBlockchainStateForChain(Connection c, String blockchainName) throws SQLException { + return executeDelete(c, """ + DELETE FROM blockchain_state + WHERE blockchain_name = ? + """, blockchainName); + } + + private int executeDelete(Connection c, String sql, String value) throws SQLException { + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, value); + return ps.executeUpdate(); + } + } + + /** + * Технический результат cleanup-операции. + * Нужен для будущего логирования и ручной диагностики resync-flow. + */ + public record CleanupResult( + String login, + int likesAdjustedRows, + int repliesAdjustedRows, + int deletedMessageStatsRows, + int deletedReactionsStateRows, + int deletedConnectionsStateRows, + int deletedUsersParamsRows, + int deletedChannelNamesRows, + int deletedChat200StateRows, + int deletedChat200MembersRows, + int deletedBlocksRows, + int deletedBlockchainStateRows + ) {} +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java index a19e879..4087e5a 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainStateDAO.java @@ -4,6 +4,8 @@ import shine.db.SqliteDbController; import shine.db.entities.BlockchainStateEntry; import java.sql.*; +import java.util.ArrayList; +import java.util.List; public final class BlockchainStateDAO { @@ -53,6 +55,39 @@ public final class BlockchainStateDAO { } } + /** Получить все blockchain_state записи. */ + public List listAll() throws SQLException { + try (Connection c = db.getConnection()) { + return listAll(c); + } + } + + /** Получить все blockchain_state записи с внешним соединением. Соединение НЕ закрывает. */ + public List listAll(Connection c) throws SQLException { + String sql = """ + SELECT + blockchain_name, + login, + blockchain_key, + size_limit, + file_size_bytes, + last_block_number, + last_block_hash, + updated_at_ms + FROM blockchain_state + ORDER BY blockchain_name COLLATE NOCASE + """; + + List result = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(mapRow(rs)); + } + } + return result; + } + /** UPSERT без внешнего соединения. Сам открывает/закрывает. */ public void upsert(BlockchainStateEntry e) throws SQLException { try (Connection c = db.getConnection()) { @@ -103,6 +138,54 @@ public final class BlockchainStateDAO { } } + /** + * Строгая вставка state только если записи ещё нет. + * + * Нужна для recovery / resync: + * - identity пользователя уже может существовать в solana_users; + * - в таком случае нам надо восстановить только blockchain_state; + * - если запись уже есть, метод просто ничего не меняет. + */ + public boolean insertIfMissing(Connection c, BlockchainStateEntry e) throws SQLException { + String sql = """ + INSERT INTO blockchain_state ( + blockchain_name, + login, + blockchain_key, + size_limit, + file_size_bytes, + last_block_number, + last_block_hash, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(blockchain_name) DO NOTHING + """; + + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + + ps.setString(i++, e.getBlockchainName()); + ps.setString(i++, nn(e.getLogin())); + ps.setString(i++, nn(e.getBlockchainKey())); + + ps.setLong(i++, e.getSizeLimit()); + ps.setLong(i++, e.getFileSizeBytes()); + + ps.setInt(i++, e.getLastBlockNumber()); + setBytesNullable(ps, i++, e.getLastBlockHash()); + + ps.setLong(i++, e.getUpdatedAtMs()); + + return ps.executeUpdate() > 0; + } + } + + public boolean insertIfMissing(BlockchainStateEntry e) throws SQLException { + try (Connection c = db.getConnection()) { + return insertIfMissing(c, e); + } + } + /** * Атомарно увеличить file_size_bytes на deltaBytes, но только если НЕ превысим size_limit. */ @@ -150,4 +233,4 @@ public final class BlockchainStateDAO { } private static String nn(String s) { return s == null ? "" : s; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 980ff49..6291e4c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -6,6 +6,8 @@ import shine.db.SqliteDbController; import shine.db.entities.BlockEntry; import java.sql.*; +import java.util.ArrayList; +import java.util.List; /** * DAO для таблицы blocks (новый формат). @@ -191,6 +193,53 @@ public final class BlocksDAO { } } + public List listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException { + try (Connection c = db.getConnection()) { + return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive); + } + } + + public List listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException { + String sql = """ + SELECT + login, + bch_name, + block_number, + msg_type, + msg_sub_type, + block_bytes, + to_login, + to_bch_name, + to_block_number, + to_block_hash, + block_hash, + block_signature, + edited_by_block_number, + line_code, + prev_line_number, + prev_line_hash, + this_line_number + FROM blocks + WHERE bch_name = ? + AND block_number >= ? + AND block_number <= ? + ORDER BY block_number ASC + """; + + List result = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, bchName); + ps.setInt(2, fromBlockNumberInclusive); + ps.setInt(3, toBlockNumberInclusive); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(mapRow(rs)); + } + } + } + return result; + } + // -------------------- INTERNAL -------------------- private BlockEntry mapRow(ResultSet rs) throws SQLException { @@ -242,4 +291,4 @@ public final class BlocksDAO { return e; } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java index 01ffa31..530f4b4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java @@ -8,10 +8,20 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; public final class SignedMessagesV2DAO { + public enum ApplyStatus { + APPLIED, + DUPLICATE_OR_OLDER, + BLOCKED_BY_MESSAGE_TOMBSTONE, + BLOCKED_BY_CONVERSATION_TOMBSTONE; + + public boolean applied() { + return this == APPLIED; + } + } + private static volatile SignedMessagesV2DAO instance; private final SqliteDbController db = SqliteDbController.getInstance(); @@ -26,18 +36,22 @@ public final class SignedMessagesV2DAO { return instance; } - public boolean insertIfAbsent(SignedMessageV2Entry e) throws Exception { + public ApplyStatus insertIfAbsent(SignedMessageV2Entry e) throws Exception { try (Connection c = db.getConnection()) { + if (isBlockedByConversationDelete(c, e.getFromLogin(), e.getToLogin(), e.getTimeMs())) { + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + } String sql = """ INSERT OR IGNORE INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); - return ps.executeUpdate() > 0; + return ps.executeUpdate() > 0 ? ApplyStatus.APPLIED : ApplyStatus.DUPLICATE_OR_OLDER; } } } @@ -67,29 +81,25 @@ public final class SignedMessagesV2DAO { } } - public boolean upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception { + public ApplyStatus upsertContentPair(SignedMessageV2Entry incoming, SignedMessageV2Entry outgoing) throws Exception { try (Connection c = db.getConnection()) { boolean prevAutoCommit = c.getAutoCommit(); c.setAutoCommit(false); try { - Long currentIncomingRevision = getRevisionTimeMs(c, incoming.getMessageKey()); - Long currentOutgoingRevision = getRevisionTimeMs(c, outgoing.getMessageKey()); - long currentRevision = Math.max( - currentIncomingRevision != null ? currentIncomingRevision : Long.MIN_VALUE, - currentOutgoingRevision != null ? currentOutgoingRevision : Long.MIN_VALUE - ); - long nextRevision = incoming.getRevisionTimeMs(); - - if (currentRevision != Long.MIN_VALUE && nextRevision < currentRevision) { + if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { c.rollback(); - return false; + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; } - if (currentRevision != Long.MIN_VALUE - && nextRevision == currentRevision - && hasSameRawBlock(c, incoming) - && hasSameRawBlock(c, outgoing)) { + if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { c.rollback(); - return false; + return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE; + } + + RevisionMarker currentMarker = getCurrentContentMarker(c, incoming.getBaseKey()); + RevisionMarker nextMarker = RevisionMarker.of(incoming); + if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) { + c.rollback(); + return ApplyStatus.DUPLICATE_OR_OLDER; } upsertMessage(c, incoming); @@ -98,7 +108,96 @@ public final class SignedMessagesV2DAO { resetDeliveryRows(c, outgoing.getMessageKey()); c.commit(); - return true; + return ApplyStatus.APPLIED; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } + } + } + + public ApplyStatus upsertIncomingCopy(SignedMessageV2Entry incoming) throws Exception { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + if (isBlockedByConversationDelete(c, incoming.getFromLogin(), incoming.getToLogin(), incoming.getTimeMs())) { + c.rollback(); + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + } + if (hasMessageDeleteTombstone(c, incoming.getBaseKey())) { + c.rollback(); + return ApplyStatus.BLOCKED_BY_MESSAGE_TOMBSTONE; + } + + RevisionMarker currentMarker = getRevisionMarkerByMessageKey(c, incoming.getMessageKey()); + RevisionMarker nextMarker = RevisionMarker.of(incoming); + if (currentMarker != null && compareMarkers(nextMarker, currentMarker) <= 0) { + c.rollback(); + return ApplyStatus.DUPLICATE_OR_OLDER; + } + + upsertMessage(c, incoming); + resetDeliveryRows(c, incoming.getMessageKey()); + c.commit(); + return ApplyStatus.APPLIED; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } + } + } + + public ApplyStatus applyDeleteMessage(SignedMessageV2Entry tombstone) throws Exception { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + if (isBlockedByConversationDelete(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs())) { + c.rollback(); + return ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE; + } + if (hasMessageDeleteTombstone(c, tombstone.getBaseKey())) { + c.rollback(); + return ApplyStatus.DUPLICATE_OR_OLDER; + } + + deleteMessageContentAndReceipts(c, tombstone.getBaseKey()); + upsertMessage(c, tombstone); + resetDeliveryRows(c, tombstone.getMessageKey()); + + c.commit(); + return ApplyStatus.APPLIED; + } catch (Exception ex) { + try { c.rollback(); } catch (Exception ignored) {} + throw ex; + } finally { + c.setAutoCommit(prevAutoCommit); + } + } + } + + public ApplyStatus applyDeleteConversation(SignedMessageV2Entry tombstone) throws Exception { + try (Connection c = db.getConnection()) { + boolean prevAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try { + Long currentBoundary = getLatestConversationDeleteBoundary(c, tombstone.getFromLogin(), tombstone.getToLogin()); + if (currentBoundary != null && tombstone.getTimeMs() <= currentBoundary) { + c.rollback(); + return ApplyStatus.DUPLICATE_OR_OLDER; + } + + deleteConversationHistoryBefore(c, tombstone.getFromLogin(), tombstone.getToLogin(), tombstone.getTimeMs()); + upsertMessage(c, tombstone); + resetDeliveryRows(c, tombstone.getMessageKey()); + + c.commit(); + return ApplyStatus.APPLIED; } catch (Exception ex) { try { c.rollback(); } catch (Exception ignored) {} throw ex; @@ -113,8 +212,9 @@ public final class SignedMessagesV2DAO { String sql = """ SELECT message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type FROM signed_messages_v2 WHERE message_key = ? """; @@ -128,6 +228,12 @@ public final class SignedMessagesV2DAO { } } + public SignedMessageV2Entry getLatestConversationDelete(String fromLogin, String toLogin) throws Exception { + try (Connection c = db.getConnection()) { + return getLatestConversationDelete(c, fromLogin, toLogin); + } + } + public void ensureDeliveryRow(String messageKey, String sessionId, long nowMs) throws Exception { try (Connection c = db.getConnection()) { String sql = """ @@ -180,26 +286,35 @@ public final class SignedMessagesV2DAO { ) SELECT m.message_key, ?, 0, NULL, ? FROM signed_messages_v2 m - WHERE m.target_login = ? COLLATE NOCASE + WHERE ( + (m.message_type IN (1, 3) AND m.to_login = ? COLLATE NOCASE) + OR (m.message_type IN (2, 4) AND m.from_login = ? COLLATE NOCASE) + OR (m.message_type IN (5, 6, 7, 8) + AND (m.from_login = ? COLLATE NOCASE OR m.to_login = ? COLLATE NOCASE)) + ) """; long now = System.currentTimeMillis(); try (PreparedStatement ps = c.prepareStatement(fillSql)) { ps.setString(1, sessionId); ps.setLong(2, now); ps.setString(3, login); + ps.setString(4, login); + ps.setString(5, login); + ps.setString(6, login); ps.executeUpdate(); } String sql = """ SELECT m.message_key, m.base_key, m.target_login, m.from_login, m.to_login, - m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.raw_block, m.created_at_ms, - m.source_api, m.origin_session_id, m.receipt_ref_base_key, m.receipt_ref_type + m.time_ms, m.nonce, m.message_type, m.revision_time_ms, m.reencrypted_at_ms, + m.raw_block, m.created_at_ms, m.source_api, m.origin_session_id, + m.receipt_ref_base_key, m.receipt_ref_type FROM signed_messages_v2 m JOIN signed_message_session_delivery d ON d.message_key = m.message_key WHERE d.session_id = ? AND d.delivered = 0 - ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.created_at_ms ASC + ORDER BY m.time_ms ASC, m.revision_time_ms ASC, m.reencrypted_at_ms ASC, m.created_at_ms ASC """; List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { @@ -216,9 +331,10 @@ public final class SignedMessagesV2DAO { String sql = """ INSERT INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(message_key) DO UPDATE SET base_key = excluded.base_key, target_login = excluded.target_login, @@ -228,6 +344,7 @@ public final class SignedMessagesV2DAO { nonce = excluded.nonce, message_type = excluded.message_type, revision_time_ms = excluded.revision_time_ms, + reencrypted_at_ms = excluded.reencrypted_at_ms, raw_block = excluded.raw_block, created_at_ms = excluded.created_at_ms, source_api = excluded.source_api, @@ -241,28 +358,167 @@ public final class SignedMessagesV2DAO { } } - private Long getRevisionTimeMs(Connection c, String messageKey) throws SQLException { - String sql = "SELECT revision_time_ms FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; + private RevisionMarker getRevisionMarkerByMessageKey(Connection c, String messageKey) throws SQLException { + String sql = """ + SELECT revision_time_ms, reencrypted_at_ms + FROM signed_messages_v2 + WHERE message_key = ? + LIMIT 1 + """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, messageKey); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) return null; - return rs.getLong(1); + return new RevisionMarker(rs.getLong(1), rs.getLong(2)); } } } - private boolean hasSameRawBlock(Connection c, SignedMessageV2Entry entry) throws SQLException { - String sql = "SELECT raw_block FROM signed_messages_v2 WHERE message_key = ? LIMIT 1"; + private RevisionMarker getCurrentContentMarker(Connection c, String baseKey) throws SQLException { + String sql = """ + SELECT revision_time_ms, reencrypted_at_ms + FROM signed_messages_v2 + WHERE base_key = ? + AND message_type IN (1, 2) + ORDER BY revision_time_ms DESC, reencrypted_at_ms DESC + LIMIT 1 + """; try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, entry.getMessageKey()); + ps.setString(1, baseKey); try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return false; - return Arrays.equals(rs.getBytes(1), entry.getRawBlock()); + if (!rs.next()) return null; + return new RevisionMarker(rs.getLong(1), rs.getLong(2)); } } } + private boolean hasMessageDeleteTombstone(Connection c, String baseKey) throws SQLException { + String sql = """ + SELECT 1 + FROM signed_messages_v2 + WHERE base_key = ? + AND message_type IN (5, 6) + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, baseKey); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + private boolean isBlockedByConversationDelete(Connection c, String fromLogin, String toLogin, long timeMs) throws SQLException { + Long boundary = getLatestConversationDeleteBoundary(c, fromLogin, toLogin); + return boundary != null && timeMs < boundary; + } + + private Long getLatestConversationDeleteBoundary(Connection c, String fromLogin, String toLogin) throws SQLException { + String sql = """ + SELECT MAX(time_ms) + FROM signed_messages_v2 + WHERE message_type IN (7, 8) + AND ( + (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + ) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, fromLogin); + ps.setString(2, toLogin); + ps.setString(3, toLogin); + ps.setString(4, fromLogin); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + long value = rs.getLong(1); + return rs.wasNull() ? null : value; + } + } + } + + private SignedMessageV2Entry getLatestConversationDelete(Connection c, String fromLogin, String toLogin) throws Exception { + String sql = """ + SELECT + message_key, base_key, target_login, from_login, to_login, + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type + FROM signed_messages_v2 + WHERE message_type IN (7, 8) + AND ( + (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + ) + ORDER BY time_ms DESC + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, fromLogin); + ps.setString(2, toLogin); + ps.setString(3, toLogin); + ps.setString(4, fromLogin); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); + } + } + } + + private void deleteMessageContentAndReceipts(Connection c, String baseKey) throws SQLException { + deleteDeliveryRowsByMessageSelection(c, """ + SELECT message_key + FROM signed_messages_v2 + WHERE (base_key = ? AND message_type IN (1, 2)) + OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) + """, baseKey, baseKey); + + try (PreparedStatement ps = c.prepareStatement(""" + DELETE FROM signed_messages_v2 + WHERE (base_key = ? AND message_type IN (1, 2)) + OR (receipt_ref_base_key = ? AND message_type IN (3, 4)) + """)) { + ps.setString(1, baseKey); + ps.setString(2, baseKey); + ps.executeUpdate(); + } + } + + private void deleteConversationHistoryBefore(Connection c, String fromLogin, String toLogin, long boundaryTimeMs) throws SQLException { + deleteDeliveryRowsByMessageSelection(c, """ + SELECT message_key + FROM signed_messages_v2 + WHERE time_ms < ? + AND ( + (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + ) + """, boundaryTimeMs, fromLogin, toLogin, toLogin, fromLogin); + + try (PreparedStatement ps = c.prepareStatement(""" + DELETE FROM signed_messages_v2 + WHERE time_ms < ? + AND ( + (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + OR (from_login = ? COLLATE NOCASE AND to_login = ? COLLATE NOCASE) + ) + """)) { + ps.setLong(1, boundaryTimeMs); + ps.setString(2, fromLogin); + ps.setString(3, toLogin); + ps.setString(4, toLogin); + ps.setString(5, fromLogin); + ps.executeUpdate(); + } + } + + private void deleteDeliveryRowsByMessageSelection(Connection c, String messageKeySelectSql, Object... bindValues) throws SQLException { + String sql = "DELETE FROM signed_message_session_delivery WHERE message_key IN (" + messageKeySelectSql + ")"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + bindObjects(ps, bindValues); + ps.executeUpdate(); + } + } + private void resetDeliveryRows(Connection c, String messageKey) throws SQLException { try (PreparedStatement ps = c.prepareStatement(""" UPDATE signed_message_session_delivery @@ -278,9 +534,10 @@ public final class SignedMessagesV2DAO { String sql = """ INSERT INTO signed_messages_v2 ( message_key, base_key, target_login, from_login, to_login, - time_ms, nonce, message_type, revision_time_ms, raw_block, created_at_ms, - source_api, origin_session_id, receipt_ref_base_key, receipt_ref_type - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + time_ms, nonce, message_type, revision_time_ms, reencrypted_at_ms, + raw_block, created_at_ms, source_api, origin_session_id, + receipt_ref_base_key, receipt_ref_type + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { bindSignedMessage(ps, e); @@ -298,13 +555,30 @@ public final class SignedMessagesV2DAO { ps.setLong(7, e.getNonce()); ps.setInt(8, e.getMessageType()); ps.setLong(9, e.getRevisionTimeMs()); - ps.setBytes(10, e.getRawBlock()); - ps.setLong(11, e.getCreatedAtMs()); - ps.setString(12, e.getSourceApi()); - ps.setString(13, e.getOriginSessionId()); - ps.setString(14, e.getReceiptRefBaseKey()); - if (e.getReceiptRefType() == null) ps.setObject(15, null); - else ps.setInt(15, e.getReceiptRefType()); + ps.setLong(10, e.getReencryptedAtMs()); + ps.setBytes(11, e.getRawBlock()); + ps.setLong(12, e.getCreatedAtMs()); + ps.setString(13, e.getSourceApi()); + ps.setString(14, e.getOriginSessionId()); + ps.setString(15, e.getReceiptRefBaseKey()); + if (e.getReceiptRefType() == null) ps.setObject(16, null); + else ps.setInt(16, e.getReceiptRefType()); + } + + private void bindObjects(PreparedStatement ps, Object... bindValues) throws SQLException { + for (int i = 0; i < bindValues.length; i++) { + Object value = bindValues[i]; + int param = i + 1; + if (value instanceof String s) { + ps.setString(param, s); + } else if (value instanceof Long l) { + ps.setLong(param, l); + } else if (value instanceof Integer n) { + ps.setInt(param, n); + } else { + ps.setObject(param, value); + } + } } private boolean isConstraintViolation(SQLException ex) { @@ -312,6 +586,12 @@ public final class SignedMessagesV2DAO { return msg.contains("constraint") || msg.contains("unique") || msg.contains("primary key"); } + private int compareMarkers(RevisionMarker left, RevisionMarker right) { + int revisionCompare = Long.compare(left.revisionTimeMs, right.revisionTimeMs); + if (revisionCompare != 0) return revisionCompare; + return Long.compare(left.reencryptedAtMs, right.reencryptedAtMs); + } + private SignedMessageV2Entry mapRow(ResultSet rs) throws Exception { SignedMessageV2Entry e = new SignedMessageV2Entry(); e.setMessageKey(rs.getString("message_key")); @@ -323,6 +603,7 @@ public final class SignedMessagesV2DAO { e.setNonce(rs.getLong("nonce")); e.setMessageType(rs.getInt("message_type")); e.setRevisionTimeMs(rs.getLong("revision_time_ms")); + e.setReencryptedAtMs(rs.getLong("reencrypted_at_ms")); e.setRawBlock(rs.getBytes("raw_block")); e.setCreatedAtMs(rs.getLong("created_at_ms")); e.setSourceApi(rs.getString("source_api")); @@ -332,4 +613,10 @@ public final class SignedMessagesV2DAO { e.setReceiptRefType(rs.wasNull() ? null : maybeRefType); return e; } + + private record RevisionMarker(long revisionTimeMs, long reencryptedAtMs) { + private static RevisionMarker of(SignedMessageV2Entry entry) { + return new RevisionMarker(entry.getRevisionTimeMs(), entry.getReencryptedAtMs()); + } + } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java new file mode 100644 index 0000000..edeeb9b --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SyncServersDAO.java @@ -0,0 +1,104 @@ +package shine.db.dao; + +import shine.db.SqliteDbController; +import shine.db.entities.SyncServerEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +/** + * DAO локальной таблицы серверов-партнёров для будущей межсерверной синхронизации. + */ +public final class SyncServersDAO { + + private static volatile SyncServersDAO instance; + private final SqliteDbController db = SqliteDbController.getInstance(); + + private SyncServersDAO() {} + + public static SyncServersDAO getInstance() { + if (instance == null) { + synchronized (SyncServersDAO.class) { + if (instance == null) instance = new SyncServersDAO(); + } + } + return instance; + } + + public List listAll() throws SQLException { + try (Connection c = db.getConnection()) { + return listAll(c); + } + } + + public List listAll(Connection c) throws SQLException { + String sql = """ + SELECT login, server_address, updated_at_ms + FROM sync_servers + ORDER BY login + """; + List result = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(mapRow(rs)); + } + } + return result; + } + + /** + * Полностью заменяет список партнёров актуальным снимком из Solana PDA. + */ + public void replaceAll(List entries) throws SQLException { + try (Connection c = db.getConnection()) { + replaceAll(c, entries); + } + } + + public void replaceAll(Connection c, List entries) throws SQLException { + boolean oldAutoCommit = c.getAutoCommit(); + c.setAutoCommit(false); + try (Statement st = c.createStatement()) { + st.executeUpdate("DELETE FROM sync_servers"); + String sql = """ + INSERT INTO sync_servers ( + login, server_address, updated_at_ms + ) VALUES (?, ?, ?) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + for (SyncServerEntry entry : entries) { + ps.setString(1, entry.getLogin()); + ps.setString(2, safe(entry.getServerAddress())); + ps.setLong(3, entry.getUpdatedAtMs()); + ps.addBatch(); + } + ps.executeBatch(); + } + c.commit(); + } catch (Exception e) { + try { c.rollback(); } catch (Exception ignored) {} + if (e instanceof SQLException sqlEx) throw sqlEx; + throw new SQLException("Не удалось обновить таблицу sync_servers", e); + } finally { + try { c.setAutoCommit(oldAutoCommit); } catch (Exception ignored) {} + } + } + + private SyncServerEntry mapRow(ResultSet rs) throws SQLException { + SyncServerEntry entry = new SyncServerEntry(); + entry.setLogin(rs.getString("login")); + entry.setServerAddress(rs.getString("server_address")); + entry.setUpdatedAtMs(rs.getLong("updated_at_ms")); + return entry; + } + + private static String safe(String value) { + return value == null ? "" : value; + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java index 59e86dc..84e4bdf 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SignedMessageV2Entry.java @@ -10,6 +10,7 @@ public class SignedMessageV2Entry { private long nonce; private int messageType; private long revisionTimeMs; + private long reencryptedAtMs; private byte[] rawBlock; private long createdAtMs; private String sourceApi; @@ -35,6 +36,8 @@ public class SignedMessageV2Entry { public void setMessageType(int messageType) { this.messageType = messageType; } public long getRevisionTimeMs() { return revisionTimeMs; } public void setRevisionTimeMs(long revisionTimeMs) { this.revisionTimeMs = revisionTimeMs; } + public long getReencryptedAtMs() { return reencryptedAtMs; } + public void setReencryptedAtMs(long reencryptedAtMs) { this.reencryptedAtMs = reencryptedAtMs; } public byte[] getRawBlock() { return rawBlock; } public void setRawBlock(byte[] rawBlock) { this.rawBlock = rawBlock; } public long getCreatedAtMs() { return createdAtMs; } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SyncServerEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SyncServerEntry.java new file mode 100644 index 0000000..c1f1007 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/SyncServerEntry.java @@ -0,0 +1,43 @@ +package shine.db.entities; + +/** + * Запись о сервере-партнёре, с которым текущий сервер должен синхронизироваться. + */ +public class SyncServerEntry { + + private String login; + private String serverAddress; + private long updatedAtMs; + + public SyncServerEntry() {} + + public SyncServerEntry(String login, String serverAddress, long updatedAtMs) { + this.login = login; + this.serverAddress = serverAddress; + this.updatedAtMs = updatedAtMs; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getServerAddress() { + return serverAddress; + } + + public void setServerAddress(String serverAddress) { + this.serverAddress = serverAddress; + } + + public long getUpdatedAtMs() { + return updatedAtMs; + } + + public void setUpdatedAtMs(long updatedAtMs) { + this.updatedAtMs = updatedAtMs; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 39e8056..2415774 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -39,7 +39,9 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_StartEspPairing_ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairingSettings_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_AddUser_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request; @@ -87,6 +89,9 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler; import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler; +import server.logic.ws_protocol.JSON.messages.Net_DeleteConversation_Handler; +import server.logic.ws_protocol.JSON.messages.Net_DeleteMessage_Handler; +import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler; import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler; import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler; @@ -95,6 +100,9 @@ import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler; import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request; @@ -104,8 +112,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Reque // --- NEW: Ping --- import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_GetCallIceConfig_Handler; +import server.logic.ws_protocol.JSON.handlers.system.Net_GetSyncUserProfile_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler; +import server.logic.ws_protocol.JSON.handlers.system.Net_ListBlockchainHeads_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_CallDeliveryReport_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_CallDeliveryReport_Request; @@ -113,6 +123,8 @@ import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request; import java.util.Map; @@ -161,6 +173,7 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", new Net_AddBlock_Handler()), + Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()), // --- userParams --- Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()), @@ -182,17 +195,21 @@ public final class JsonHandlerRegistry { // --- direct messages / push --- Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), Map.entry("SendTestWebPush", new Net_SendTestWebPush_Handler()), - Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()), Map.entry("SendMessagePair", new Net_SendMessagePair_Handler()), Map.entry("ReceiveOutcomingMessage", new Net_SendMessagePair_Handler()), Map.entry("ReceiveIncomingMessage", new Net_ReceiveIncomingMessage_Handler()), + Map.entry("DeleteMessage", new Net_DeleteMessage_Handler()), + Map.entry("DeleteConversation", new Net_DeleteConversation_Handler()), Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()), Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()), Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()), + Map.entry("SendSignal", new Net_SendSignal_Handler()), // --- system --- Map.entry("Ping", new Net_Ping_Handler()), Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()), + Map.entry("ListBlockchainHeads", new Net_ListBlockchainHeads_Handler()), + Map.entry("GetSyncUserProfile", new Net_GetSyncUserProfile_Handler()), Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()), Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()), Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()), @@ -236,6 +253,7 @@ public final class JsonHandlerRegistry { // --- blockchain --- Map.entry("AddBlock", Net_AddBlock_Request.class), + Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class), // --- userParams --- Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class), @@ -257,17 +275,21 @@ public final class JsonHandlerRegistry { // --- direct messages / push --- Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), Map.entry("SendTestWebPush", Net_SendTestWebPush_Request.class), - Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class), Map.entry("SendMessagePair", Net_SendMessagePair_Request.class), Map.entry("ReceiveOutcomingMessage", Net_SendMessagePair_Request.class), Map.entry("ReceiveIncomingMessage", Net_ReceiveIncomingMessage_Request.class), + Map.entry("DeleteMessage", Net_DeleteMessage_Request.class), + Map.entry("DeleteConversation", Net_DeleteConversation_Request.class), Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class), Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class), Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class), + Map.entry("SendSignal", Net_SendSignal_Request.class), // --- system --- Map.entry("Ping", Net_Ping_Request.class), Map.entry("GetServerInfo", Net_GetServerInfo_Request.class), + Map.entry("ListBlockchainHeads", Net_ListBlockchainHeads_Request.class), + Map.entry("GetSyncUserProfile", Net_GetSyncUserProfile_Request.class), Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class), Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class), Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java index 4086c0e..2e963c7 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CreateAuthSession__Handler.java @@ -51,6 +51,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_CreateAuthSession__Handler.class); private static final SecureRandom RANDOM = new SecureRandom(); private static final long CLOSE_AFTER_ERROR_DELAY_MS = 75L; + private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L; public static final long ALLOWED_SKEW_MS = 30_000L; @@ -423,7 +424,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler { ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ActiveConnectionsRegistry.getInstance().register(ctx); - SignedMessagesRealtime.dispatchPendingForSession(ctx); + SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS); // --- формируем ответ --- Net_CreateAuthSession_Response resp = new Net_CreateAuthSession_Response(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java index 7859c3e..bc4685e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_SessionLogin_Handler.java @@ -44,6 +44,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { private static final Logger log = LoggerFactory.getLogger(Net_SessionLogin_Handler.class); private static final long ALLOWED_SKEW_MS = 30_000L; + private static final long SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS = 250L; @Override public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { @@ -301,7 +302,7 @@ public class Net_SessionLogin_Handler implements JsonMessageHandler { ctx.setAuthenticationStatus(ConnectionContext.AUTH_STATUS_USER); ActiveConnectionsRegistry.getInstance().register(ctx); - SignedMessagesRealtime.dispatchPendingForSession(ctx); + SignedMessagesRealtime.dispatchPendingForSessionAsync(ctx, SIGNED_DM_BACKLOG_AFTER_AUTH_DELAY_MS); // ответ Net_SessionLogin_Response resp = new Net_SessionLogin_Response(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 53f65a7..60065c9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -16,8 +16,10 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; /** * Lazy-import пользователя из Solana PDA в локальную БД сервера. @@ -82,7 +84,56 @@ public final class SolanaUserPdaImportService { return SessionTypeCheckResult.noRecord(); } + /** + * Чтение server PDA по логину сервера. Используется сервером при старте, + * чтобы получить актуальный server_address и список sync_servers. + */ + public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception { + String login = normalizeLogin(loginRaw); + if (login == null) return null; + + byte[] raw = fetchRawUserPda(login); + if (raw == null) return null; + + ParsedServerProfile parsed = parseServerProfile(raw); + if (parsed == null) return null; + if (!parsed.login.equalsIgnoreCase(login)) return null; + return parsed; + } + + public static List fetchAccessServerRoutesByLogin(String loginRaw) throws Exception { + String login = normalizeLogin(loginRaw); + if (login == null) return List.of(); + + ParsedSolanaUser parsed = fetchFromSolana(login); + if (parsed == null) return List.of(); + + Map routes = new LinkedHashMap<>(); + for (String accessServerLogin : parsed.accessServers()) { + String normalized = normalizeLogin(accessServerLogin); + if (normalized == null) continue; + + ParsedServerProfile serverProfile = fetchServerProfileByLogin(normalized); + if (serverProfile == null || !serverProfile.isServer()) continue; + + String serverAddress = safe(serverProfile.serverAddress()); + if (serverAddress.isBlank()) continue; + routes.putIfAbsent(normalized, new ParsedServerRoute(normalized, serverAddress)); + } + return new ArrayList<>(routes.values()); + } + private static ParsedSolanaUser fetchFromSolana(String login) throws Exception { + byte[] raw = fetchRawUserPda(login); + if (raw == null) return null; + ParsedSolanaUser parsed = parseUserPda(raw); + if (parsed != null && parsed.login.equalsIgnoreCase(login)) { + return parsed; + } + return null; + } + + private static byte[] fetchRawUserPda(String login) throws Exception { String loginB58 = toBase58(login.getBytes(StandardCharsets.UTF_8)); String lenB58 = toBase58(new byte[]{(byte) login.length()}); @@ -128,11 +179,7 @@ public final class SolanaUserPdaImportService { if (!dataNode.isArray() || dataNode.size() < 1) continue; String b64 = dataNode.get(0).asText(""); if (b64.isBlank()) continue; - byte[] raw = Base64.getDecoder().decode(b64); - ParsedSolanaUser parsed = parseUserPda(raw); - if (parsed != null && parsed.login.equalsIgnoreCase(login)) { - return parsed; - } + return Base64.getDecoder().decode(b64); } return null; } @@ -161,6 +208,7 @@ public final class SolanaUserPdaImportService { byte[] clientKey32 = null; long paidLimitBytes = 0L; List sessions = new ArrayList<>(); + List accessServers = new ArrayList<>(); for (int i = 0; i < blocksCount; i++) { int blockType = u8(raw, c++); @@ -216,7 +264,9 @@ public final class SolanaUserPdaImportService { int accessCount = u8(raw, c++); for (int j = 0; j < accessCount; j++) { int n = u8(raw, c++); + String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8); c += n; + accessServers.add(normalizeLogin(accessServerLogin)); } } else if (blockType == 50) { int sessionsMode = u8(raw, c++); @@ -254,10 +304,110 @@ public final class SolanaUserPdaImportService { Base64.getEncoder().encodeToString(blockchainKey32), Base64.getEncoder().encodeToString(clientKey32), paidLimitBytes, - sessions + sessions, + accessServers ); } + private static ParsedServerProfile parseServerProfile(byte[] raw) { + if (raw == null || raw.length < 128) return null; + if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null; + + int recordLen = u16le(raw, 7); + if (recordLen < 73 || recordLen > raw.length) return null; + + int c = 9; + c += 8; // created_at_ms + c += 8; // updated_at_ms + c += 4; // record_number + c += 32; // prev_record_hash + + int loginLen = u8(raw, c++); + if (loginLen <= 0 || c + loginLen > recordLen) return null; + String login = new String(raw, c, loginLen, StandardCharsets.UTF_8); + c += loginLen; + + int blocksCount = u8(raw, c++); + boolean isServer = false; + String serverAddress = ""; + List syncServers = new ArrayList<>(); + + for (int i = 0; i < blocksCount; i++) { + int blockType = u8(raw, c++); + int blockVer = u8(raw, c++); + if (blockVer != 0) return null; + + if (blockType == 0 || blockType == 1 || blockType == 2) { + c += 32; + } else if (blockType == 3) { + int count = u8(raw, c++); + for (int j = 0; j < count; j++) { + c += 1; // blockchain_type + int bchLen = u8(raw, c++); + c += bchLen; + c += 32; // blockchain pubkey + c += 8; // paid_limit_bytes + c += 8; // used_bytes + c += 4; // last_block_number + c += 32; // last_block_hash + c += 64; // last_block_signature + int arweavePresent = u8(raw, c++); + if (arweavePresent == 1) { + int arLen = u8(raw, c++); + c += arLen; + } else if (arweavePresent != 0) { + return null; + } + } + } else if (blockType == 30) { + int isServerValue = u8(raw, c++); + if (isServerValue == 1) { + isServer = true; + c += 1; // address_format_type + c += 1; // address_format_version + int addrLen = u8(raw, c++); + serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8); + c += addrLen; + int syncCount = u8(raw, c++); + for (int j = 0; j < syncCount; j++) { + int n = u8(raw, c++); + String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8); + c += n; + syncServers.add(normalizeLogin(syncLogin)); + } + } else if (isServerValue != 0) { + return null; + } + } else if (blockType == 40) { + int accessCount = u8(raw, c++); + for (int j = 0; j < accessCount; j++) { + int n = u8(raw, c++); + c += n; + } + } else if (blockType == 50) { + int sessionsMode = u8(raw, c++); + if (sessionsMode != 1 && sessionsMode != 10) return null; + int sessionsCount = u8(raw, c++); + if (sessionsCount > 64) return null; + for (int j = 0; j < sessionsCount; j++) { + c += 1; // session_type + c += 1; // session_version + int n = u8(raw, c++); + c += n; + c += 32; + } + } else if (blockType == 70) { + c += 1; + } else { + return null; + } + + if (c > recordLen) return null; + } + + return new ParsedServerProfile(login, isServer, serverAddress, syncServers); + } + private static String normalizeLogin(String login) { if (login == null) return null; String s = login.trim(); @@ -316,13 +466,18 @@ public final class SolanaUserPdaImportService { return diff == 0; } + private static String safe(String value) { + return value == null ? "" : value.trim(); + } + private record ParsedSolanaUser( String login, String blockchainName, String blockchainKeyB64, String clientKeyB64, long paidLimitBytes, - List sessions + List sessions, + List accessServers ) {} private record ParsedSessionRecord( @@ -350,4 +505,16 @@ public final class SolanaUserPdaImportService { return new SessionTypeCheckResult(true, false, pdaSessionType, sessionName == null ? "" : sessionName); } } + + public record ParsedServerProfile( + String login, + boolean isServer, + String serverAddress, + List syncServers + ) {} + + public record ParsedServerRoute( + String login, + String serverAddress + ) {} } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 5b98a18..02df74a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -17,11 +17,13 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import server.sync.BlockchainResyncGuard; import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainWriter; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; import server.logic.ws_protocol.WireCodes; +import server.sync.AddBlockSyncService; import shine.db.channels.ChannelNameRules; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; @@ -55,6 +57,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); private final UserParamsDAO userParamsDAO = UserParamsDAO.getInstance(); private final ChannelNameStateDAO channelNameStateDAO = ChannelNameStateDAO.getInstance(); + private final AddBlockSyncService addBlockSyncService = new AddBlockSyncService(); private final BlockchainWriter dbWriter = new BlockchainWriter(blocksDAO, stateDAO, userParamsDAO, channelNameStateDAO); @@ -68,6 +71,21 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { Net_AddBlock_Request req = (Net_AddBlock_Request) baseReq; String blockchainName = req.getBlockchainName(); + if (blockchainName == null || blockchainName.isBlank()) { + return error(req, WireCodes.Status.BAD_REQUEST, "empty_blockchain_name", 0, ""); + } + if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) { + BlockchainStateEntry currentState = null; + try { + currentState = stateDAO.getByBlockchainName(blockchainName); + } catch (Exception ignored) { + } + int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1; + String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : ""; + return error(req, 423, "chain_resync_in_progress", + lastNum, + lastHash); + } ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); lock.lock(); try { @@ -124,7 +142,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } private static String humanMessage(String code) { - if (code == null) return "Ошибка добавления блока"; return switch (code) { + if (code == null) return "Ошибка добавления блока"; + return switch (code) { case "empty_blockchain_name" -> "Пустое имя блокчейна"; case "bad_blockchain_name" -> "Некорректное имя блокчейна"; case "db_error" -> "Ошибка базы данных"; @@ -148,6 +167,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "channel_name_already_exists" -> "Такое название канала уже занято"; case "repost_disabled" -> "Репосты временно отключены до будущей реализации"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; + case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; default -> "Ошибка: " + code; }; } @@ -484,6 +504,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}", login, blockchainName, block.blockNumber, newHashHex); + addBlockSyncService.replicateAsync(blockchainName, block.blockNumber); + return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 387829f..6a5ab99 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -11,18 +11,32 @@ import shine.db.entities.ChannelNameStateEntry; import shine.db.entities.UserParamEntry; import utils.files.FileStoreUtil; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.sql.Connection; import java.sql.SQLException; +import java.util.HexFormat; /** - * BlockchainWriter — запись блока в DB + обновление state + запись в файл. + * BlockchainWriter — запись блока в БД и формирование файловой версии цепочки. * - * ВАЖНО: - * - Это минимальный рабочий вариант под новый формат. - * - Если у тебя уже есть "атомарность" сложнее (tmp_bch + commit/recovery) — можно усилить потом. + * Текущая схема: + * 1) собираем .tmp_bch как готовый кандидат на замену основного файла; + * 2) пишем маленький sidecar .write_check с blockNumber/blockHash; + * 3) создаём пустой marker .write_pending; + * 4) выполняем одну SQL-транзакцию; + * 5) после commit атомарно заменяем основной .bch из tmp; + * 6) убираем временные файлы. + * + * Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись: + * - если marker отсутствует, а tmp/check остались, это мусор; + * - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор. */ public final class BlockchainWriter { + private static final HexFormat HEX = HexFormat.of(); + private final BlocksDAO blocksDAO; private final BlockchainStateDAO stateDAO; private final ChannelNameStateDAO channelNameStateDAO; @@ -47,7 +61,13 @@ public final class BlockchainWriter { ChannelNameStateEntry channelNameStateEntry) throws SQLException { long nowMs = System.currentTimeMillis(); + byte[] blockBytes = block.toBytes(); + byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes); + String blockHashHex = HEX.formatHex(block.getHash32()); + prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes); + + boolean committed = false; try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) { c.setAutoCommit(false); try { @@ -57,7 +77,7 @@ public final class BlockchainWriter { // 2) update state st.setLastBlockNumber(block.blockNumber); st.setLastBlockHash(block.getHash32()); - st.setFileSizeBytes(st.getFileSizeBytes() + block.toBytes().length); + st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length); st.setUpdatedAtMs(nowMs); stateDAO.upsert(c, st); @@ -72,18 +92,80 @@ public final class BlockchainWriter { } c.commit(); + committed = true; } catch (Exception e) { - try { c.rollback(); } catch (Exception ignored) {} - if (e instanceof SQLException se) throw se; + try { + c.rollback(); + } catch (Exception ignored) { + } + + if (!committed) { + cleanupWriteArtifactsBestEffort(blockchainName); + } + + if (e instanceof SQLException se) { + throw se; + } throw new SQLException("appendBlockAndState failed", e); } finally { - try { c.setAutoCommit(true); } catch (Exception ignored) {} + try { + c.setAutoCommit(true); + } catch (Exception ignored) { + } } } - // 3) append to file (минимально: просто дописать) - // Если у тебя уже есть логика tmp_bch+atomicReplace — можно заменить тут. - String fileName = fs.buildBlockchainFileName(blockchainName); - fs.addDataToFile(fileName, block.toBytes()); + // 3) После commit — атомарно подменяем основной файл. + try { + fs.atomicReplaceBlockchainFile(blockchainName); + } catch (RuntimeException e) { + // marker/check/tmp оставляем для startup-recovery + throw e; + } + + // 4) После успешной подмены — чистим временные артефакты. + cleanupWriteArtifactsBestEffort(blockchainName); + } + + private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) { + byte[] base; + try { + if (Files.exists(fs.resolveBlockchainPath(blockchainName))) { + base = fs.readBlockchain(blockchainName); + } else { + base = new byte[0]; + } + } catch (RuntimeException e) { + throw e; + } + + byte[] out = new byte[base.length + blockBytes.length]; + System.arraycopy(base, 0, out, 0, base.length); + System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length); + return out; + } + + private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) { + fs.writeBlockchainTmp(blockchainName, candidateBytes); + fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex); + fs.writeBlockchainWritePendingMarker(blockchainName); + } + + private void cleanupWriteArtifactsBestEffort(String blockchainName) { + deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName)); + deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName)); + deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName)); + } + + private static void deleteQuietly(DeleteAction action) { + try { + action.run(); + } catch (RuntimeException ignored) { + } + } + + @FunctionalInterface + private interface DeleteAction { + void run(); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetBlockchainBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetBlockchainBlock_Handler.java new file mode 100644 index 0000000..05bee04 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_GetBlockchainBlock_Handler.java @@ -0,0 +1,80 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.BlocksDAO; +import shine.db.entities.BlockEntry; + +/** + * GetBlockchainBlock — публичное чтение одного конкретного блока по имени цепочки и номеру. + */ +public final class Net_GetBlockchainBlock_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_GetBlockchainBlock_Handler.class); + private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetBlockchainBlock_Request req = (Net_GetBlockchainBlock_Request) baseRequest; + + String blockchainName = req.getBlockchainName() == null ? "" : req.getBlockchainName().trim(); + if (blockchainName.isEmpty() || req.getBlockNumber() < 0) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", + "Некорректные поля: blockchainName, blockNumber" + ); + } + + try { + BlockEntry block = blocksDAO.getByNumber(blockchainName, req.getBlockNumber()); + if (block == null || block.getBlockBytes() == null) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.NOT_FOUND, + "BLOCK_NOT_FOUND", + "Блок не найден" + ); + } + + Net_GetBlockchainBlock_Response resp = new Net_GetBlockchainBlock_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setBlockchainName(block.getBchName()); + resp.setBlockNumber(block.getBlockNumber()); + resp.setBlockHash(toHex(block.getBlockHash())); + resp.setBlockBytesB64(Base64Ws.encode(block.getBlockBytes())); + return resp; + } catch (Exception e) { + log.error("❌ Internal error GetBlockchainBlock blockchainName={} blockNumber={}", + blockchainName, req.getBlockNumber(), e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetBlockchainBlock", e) + ); + } + } + + private static String toHex(byte[] bytes) { + if (bytes == null) return ""; + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >>> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetBlockchainBlock_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetBlockchainBlock_Request.java new file mode 100644 index 0000000..d2aa26c --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetBlockchainBlock_Request.java @@ -0,0 +1,15 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public final class Net_GetBlockchainBlock_Request extends Net_Request { + + private String blockchainName; + private int blockNumber; + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public int getBlockNumber() { return blockNumber; } + public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetBlockchainBlock_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetBlockchainBlock_Response.java new file mode 100644 index 0000000..26036a8 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/entyties/Net_GetBlockchainBlock_Response.java @@ -0,0 +1,23 @@ +package server.logic.ws_protocol.JSON.handlers.blockchain.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public final class Net_GetBlockchainBlock_Response extends Net_Response { + + private String blockchainName; + private int blockNumber; + private String blockHash; + private String blockBytesB64; + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public int getBlockNumber() { return blockNumber; } + public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; } + + public String getBlockHash() { return blockHash; } + public void setBlockHash(String blockHash) { this.blockHash = blockHash; } + + public String getBlockBytesB64() { return blockBytesB64; } + public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java new file mode 100644 index 0000000..9bf9c58 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetSyncUserProfile_Handler.java @@ -0,0 +1,86 @@ +package server.logic.ws_protocol.JSON.handlers.system; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetSyncUserProfile_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.BlockchainStateDAO; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.SolanaUserEntry; + +/** + * GetSyncUserProfile — server-to-server профиль пользователя для межсерверной синхронизации. + * Нужен, чтобы принимающий сервер мог создать локальные solana_users + blockchain_state + * без прямого запроса в Solana RPC. + */ +public final class Net_GetSyncUserProfile_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_GetSyncUserProfile_Handler.class); + private final SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance(); + private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetSyncUserProfile_Request req = (Net_GetSyncUserProfile_Request) baseRequest; + + String login = req.getLogin() == null ? "" : req.getLogin().trim(); + if (login.isEmpty()) { + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", + "Некорректные поля: login" + ); + } + + try { + SolanaUserEntry user = usersDAO.getByLogin(login); + + Net_GetSyncUserProfile_Response resp = new Net_GetSyncUserProfile_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + if (user == null) { + resp.setExists(false); + return resp; + } + + BlockchainStateEntry state = stateDAO.getByBlockchainName(user.getBlockchainName()); + if (state == null) { + log.warn("GetSyncUserProfile: blockchain_state not found for login={} blockchainName={}", + user.getLogin(), user.getBlockchainName()); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.NOT_FOUND, + "BLOCKCHAIN_STATE_NOT_FOUND", + "Состояние блокчейна пользователя не найдено" + ); + } + + resp.setExists(true); + resp.setLogin(user.getLogin()); + resp.setBlockchainName(user.getBlockchainName()); + resp.setSolanaKey(user.getSolanaKey()); + resp.setBlockchainKey(user.getBlockchainKey()); + resp.setClientKey(user.getClientKey()); + resp.setBlockchainSizeLimitBytes(state.getSizeLimit()); + return resp; + } catch (Exception e) { + log.error("❌ Internal error GetSyncUserProfile login={}", login, e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при GetSyncUserProfile", e) + ); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ListBlockchainHeads_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ListBlockchainHeads_Handler.java new file mode 100644 index 0000000..8a84e7f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ListBlockchainHeads_Handler.java @@ -0,0 +1,76 @@ +package server.logic.ws_protocol.JSON.handlers.system; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ListBlockchainHeads_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.BlockchainStateDAO; +import shine.db.entities.BlockchainStateEntry; + +import java.util.ArrayList; +import java.util.List; + +/** + * ListBlockchainHeads — получить краткую сводку по всем blockchain_state. + * Используется для межсерверной сверки heads перед догоняющей синхронизацией. + */ +public class Net_ListBlockchainHeads_Handler implements JsonMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(Net_ListBlockchainHeads_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_ListBlockchainHeads_Request req = (Net_ListBlockchainHeads_Request) baseRequest; + + try { + List states = BlockchainStateDAO.getInstance().listAll(); + + Net_ListBlockchainHeads_Response resp = new Net_ListBlockchainHeads_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + List items = new ArrayList<>(states.size()); + for (BlockchainStateEntry state : states) { + Net_ListBlockchainHeads_Response.Item item = new Net_ListBlockchainHeads_Response.Item(); + item.setBlockchainName(state.getBlockchainName()); + item.setLastBlockNumber(state.getLastBlockNumber()); + item.setLastBlockHash(toHex32(state.getLastBlockHash())); + item.setFileSizeBytes(state.getFileSizeBytes()); + items.add(item); + } + resp.setBlockchains(items); + + return resp; + } catch (Exception e) { + log.error("❌ Internal error ListBlockchainHeads", e); + return NetExceptionResponseFactory.error( + req, + WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", + NetExceptionResponseFactory.detailedMessage("Внутренняя ошибка сервера при ListBlockchainHeads", e) + ); + } + } + + private static String toHex32(byte[] bytes32) { + byte[] b = bytes32; + if (b == null || b.length != 32) { + b = new byte[32]; + } + final char[] hex = "0123456789abcdef".toCharArray(); + char[] out = new char[64]; + for (int i = 0; i < 32; i++) { + int v = b[i] & 0xFF; + out[i * 2] = hex[v >>> 4]; + out[i * 2 + 1] = hex[v & 0x0F]; + } + return new String(out); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetSyncUserProfile_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetSyncUserProfile_Request.java new file mode 100644 index 0000000..8de85e6 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetSyncUserProfile_Request.java @@ -0,0 +1,14 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Запрос межсерверного профиля пользователя для синхронизации. + */ +public class Net_GetSyncUserProfile_Request extends Net_Request { + + private String login; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetSyncUserProfile_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetSyncUserProfile_Response.java new file mode 100644 index 0000000..aed4a26 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetSyncUserProfile_Response.java @@ -0,0 +1,38 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** + * Ответ межсерверного профиля пользователя для синхронизации. + */ +public class Net_GetSyncUserProfile_Response extends Net_Response { + + private Boolean exists; + private String login; + private String blockchainName; + private String solanaKey; + private String blockchainKey; + private String clientKey; + private Long blockchainSizeLimitBytes; + + public Boolean getExists() { return exists; } + public void setExists(Boolean exists) { this.exists = exists; } + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public String getSolanaKey() { return solanaKey; } + public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; } + + public String getBlockchainKey() { return blockchainKey; } + public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } + + public String getClientKey() { return clientKey; } + public void setClientKey(String clientKey) { this.clientKey = clientKey; } + + public Long getBlockchainSizeLimitBytes() { return blockchainSizeLimitBytes; } + public void setBlockchainSizeLimitBytes(Long blockchainSizeLimitBytes) { this.blockchainSizeLimitBytes = blockchainSizeLimitBytes; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ListBlockchainHeads_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ListBlockchainHeads_Request.java new file mode 100644 index 0000000..9335957 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ListBlockchainHeads_Request.java @@ -0,0 +1,9 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Пустой запрос для получения списка heads всех blockchain_state. + */ +public class Net_ListBlockchainHeads_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ListBlockchainHeads_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ListBlockchainHeads_Response.java new file mode 100644 index 0000000..debeb90 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ListBlockchainHeads_Response.java @@ -0,0 +1,33 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_ListBlockchainHeads_Response extends Net_Response { + + public static final class Item { + private String blockchainName; + private int lastBlockNumber; + private String lastBlockHash; + private long fileSizeBytes; + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public int getLastBlockNumber() { return lastBlockNumber; } + public void setLastBlockNumber(int lastBlockNumber) { this.lastBlockNumber = lastBlockNumber; } + + public String getLastBlockHash() { return lastBlockHash; } + public void setLastBlockHash(String lastBlockHash) { this.lastBlockHash = lastBlockHash; } + + public long getFileSizeBytes() { return fileSizeBytes; } + public void setFileSizeBytes(long fileSizeBytes) { this.fileSizeBytes = fileSizeBytes; } + } + + private List blockchains = new ArrayList<>(); + + public List getBlockchains() { return blockchains; } + public void setBlockchains(List blockchains) { this.blockchains = blockchains; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java index 9e28295..728b8d3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/tempToTest/Net_GetUser_Handler.java @@ -5,6 +5,7 @@ import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Response; @@ -42,6 +43,9 @@ public class Net_GetUser_Handler implements JsonMessageHandler { try { SolanaUserEntry u = usersDAO.getByLogin(req.getLogin()); + if (u == null) { + u = SolanaUserPdaImportService.findOrImportByLogin(req.getLogin()); + } Net_GetUser_Response resp = new Net_GetUser_Response(); resp.setOp(req.getOp()); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/EncryptedBodyPayload.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/EncryptedBodyPayload.java new file mode 100644 index 0000000..ec99654 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/EncryptedBodyPayload.java @@ -0,0 +1,70 @@ +package server.logic.ws_protocol.JSON.messages; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +final class EncryptedBodyPayload { + static final int CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1; + + final int cryptoMethod; + final int cryptoVersion; + final byte[] ephemeralPubKey; + final byte[] iv; + final byte[] cipherText; + + private EncryptedBodyPayload( + int cryptoMethod, + int cryptoVersion, + byte[] ephemeralPubKey, + byte[] iv, + byte[] cipherText + ) { + this.cryptoMethod = cryptoMethod; + this.cryptoVersion = cryptoVersion; + this.ephemeralPubKey = ephemeralPubKey; + this.iv = iv; + this.cipherText = cipherText; + } + + static EncryptedBodyPayload parse(byte[] body, int maxBodyBytes) { + if (body == null || body.length < 1 + 1 + 1 + 1 + 4) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + ByteBuffer bb = ByteBuffer.wrap(body).order(ByteOrder.BIG_ENDIAN); + + int cryptoMethod = Byte.toUnsignedInt(bb.get()); + int cryptoVersion = Byte.toUnsignedInt(bb.get()); + if (cryptoMethod != CRYPTO_METHOD_X25519_HKDF_AES_GCM || cryptoVersion != 0) { + throw new IllegalArgumentException("BAD_CRYPTO_METHOD"); + } + + int ephemeralPubKeyLen = Byte.toUnsignedInt(bb.get()); + if (ephemeralPubKeyLen <= 0 || bb.remaining() < ephemeralPubKeyLen) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + byte[] ephemeralPubKey = new byte[ephemeralPubKeyLen]; + bb.get(ephemeralPubKey); + + int ivLen = Byte.toUnsignedInt(bb.get()); + if (ivLen <= 0 || bb.remaining() < ivLen + 4) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + byte[] iv = new byte[ivLen]; + bb.get(iv); + + long cipherTextLen = Integer.toUnsignedLong(bb.getInt()); + if (cipherTextLen <= 0 || cipherTextLen > maxBodyBytes || bb.remaining() != cipherTextLen) { + throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + } + byte[] cipherText = new byte[(int) cipherTextLen]; + bb.get(cipherText); + + return new EncryptedBodyPayload( + cryptoMethod, + cryptoVersion, + ephemeralPubKey, + iv, + cipherText + ); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java index d065f7e..60ec4df 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_CallSignalToSession_Handler.java @@ -2,6 +2,8 @@ package server.logic.ws_protocol.JSON.messages; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.Net_Request; @@ -24,6 +26,7 @@ import java.util.List; import java.util.Set; public class Net_CallSignalToSession_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_CallSignalToSession_Handler.class); private static final ObjectMapper MAPPER = new ObjectMapper(); private static final int TYPE_ACCEPT = 120; private static final int TYPE_DECLINE_BUSY = 130; @@ -55,28 +58,15 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler { ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); if (targetCtx == null || !to.equalsIgnoreCase(targetCtx.getLogin())) { - // Fallback: если точной сессии уже нет (переподключение), но у пользователя ровно 1 активная сессия, - // отправляем в неё, чтобы не ронять звонок из-за устаревшего targetSessionId. - Set activeForLogin = ActiveConnectionsRegistry.getInstance().getByLogin(to); - if (activeForLogin.size() == 1) { - targetCtx = activeForLogin.iterator().next(); - } else { - return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена"); - } + logSessionNotFound(ctx, to, targetSessionId, callId, type, "target_ctx_missing_or_login_mismatch"); + return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена"); + } + boolean delivered = sendSignalEvent(targetCtx, ctx.getLogin(), ctx.getSessionId(), to, callId, type, data); + String deliveredSessionId = String.valueOf(targetCtx.getSessionId() == null ? "" : targetCtx.getSessionId()).trim(); + if (!delivered) { + logSessionNotFound(ctx, to, targetSessionId, callId, type, "target_ws_not_delivered"); + return NetExceptionResponseFactory.error(req, 404, "SESSION_NOT_FOUND", "Целевая сессия не найдена"); } - - String eventId = NetIdGenerator.eventId("evt"); - ObjectNode payload = MAPPER.createObjectNode(); - payload.put("eventId", eventId); - payload.put("fromLogin", ctx.getLogin()); - payload.put("fromSessionId", ctx.getSessionId()); - payload.put("toLogin", to); - payload.put("callId", callId); - payload.put("type", type); - payload.put("data", data); - payload.put("timeMs", System.currentTimeMillis()); - - boolean delivered = WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload); if (type == TYPE_ACCEPT) { notifyStopOnOtherSessions( @@ -101,7 +91,7 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler { ); notifyStopOnOtherSessions( to, - targetCtx.getSessionId(), + deliveredSessionId, ctx.getLogin(), ctx.getSessionId(), callId, @@ -177,10 +167,61 @@ public class Net_CallSignalToSession_Handler implements JsonMessageHandler { } } + private boolean sendSignalEvent( + ConnectionContext targetCtx, + String fromLogin, + String fromSessionId, + String toLogin, + String callId, + int type, + String data + ) throws Exception { + if (targetCtx == null || targetCtx.getWsSession() == null || !targetCtx.getWsSession().isOpen()) { + return false; + } + String eventId = NetIdGenerator.eventId("evt"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("eventId", eventId); + payload.put("fromLogin", fromLogin); + payload.put("fromSessionId", fromSessionId); + payload.put("toLogin", toLogin); + payload.put("callId", callId); + payload.put("type", type); + payload.put("data", data); + payload.put("timeMs", System.currentTimeMillis()); + return WsEventSender.sendEvent(targetCtx, "IncomingCallSignal", eventId, payload); + } + private boolean isBlank(String s) { return s == null || s.isBlank(); } + private void logSessionNotFound( + ConnectionContext fromCtx, + String toLogin, + String targetSessionId, + String callId, + Integer type, + String reason + ) { + int activeSessionsCount = 0; + try { + activeSessionsCount = ActiveConnectionsRegistry.getInstance().getByLogin(toLogin).size(); + } catch (Exception ignored) { + } + log.warn( + "CALL_SIGNAL_SESSION_NOT_FOUND fromLogin={} fromSessionId={} toLogin={} targetSessionId={} callId={} type={} activeSessionsCount={} reason={}", + fromCtx != null ? String.valueOf(fromCtx.getLogin()) : "", + fromCtx != null ? String.valueOf(fromCtx.getSessionId()) : "", + String.valueOf(toLogin), + String.valueOf(targetSessionId), + String.valueOf(callId), + type == null ? "" : String.valueOf(type), + activeSessionsCount, + String.valueOf(reason) + ); + } + private static String jsonEscape(String s) { if (s == null) return ""; StringBuilder out = new StringBuilder(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java new file mode 100644 index 0000000..a8a7f50 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteConversation_Handler.java @@ -0,0 +1,66 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteConversation_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import server.sync.DmFederationService; +import shine.db.dao.SignedMessagesV2DAO; +import shine.db.entities.SignedMessageV2Entry; + +public class Net_DeleteConversation_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_DeleteConversation_Request req = (Net_DeleteConversation_Request) baseRequest; + if (isBlank(req.getBlobB64())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен"); + } + + final SignedMessageBlock block; + try { + block = SignedMessagesCore.parseFromB64(req.getBlobB64()); + } catch (IllegalArgumentException ex) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат блока удаления переписки"); + } + if (!block.isConversationDeleteType()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "DeleteConversation принимает только типы 7/8"); + } + + try { + SignedMessagesCore.verifyUsersAndSignature(block); + } catch (IllegalArgumentException ex) { + String code = ex.getMessage(); + int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED; + return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); + } + + SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteConversation", null); + SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteConversation(entry); + + SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); + if (status.applied()) { + counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); + } + if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { + DmFederationService.fanOutDeleteConversation(block.fromLogin, block.toLogin, block.messageType, req.getBlobB64().trim()); + } + + Net_DeleteConversation_Response resp = new Net_DeleteConversation_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setMessageKey(entry.getMessageKey()); + resp.setBaseKey(entry.getBaseKey()); + resp.setDeliveredWsSessions(counters.wsDelivered); + resp.setDeliveredWebPushSessions(counters.pushDelivered); + return resp; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java new file mode 100644 index 0000000..aac091c --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_DeleteMessage_Handler.java @@ -0,0 +1,66 @@ +package server.logic.ws_protocol.JSON.messages; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_DeleteMessage_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import server.sync.DmFederationService; +import shine.db.dao.SignedMessagesV2DAO; +import shine.db.entities.SignedMessageV2Entry; + +public class Net_DeleteMessage_Handler implements JsonMessageHandler { + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_DeleteMessage_Request req = (Net_DeleteMessage_Request) baseRequest; + if (isBlank(req.getBlobB64())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "blobB64 обязателен"); + } + + final SignedMessageBlock block; + try { + block = SignedMessagesCore.parseFromB64(req.getBlobB64()); + } catch (IllegalArgumentException ex) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат удаляющего блока"); + } + if (!block.isMessageDeleteType()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "DeleteMessage принимает только типы 5/6"); + } + + try { + SignedMessagesCore.verifyUsersAndSignature(block); + } catch (IllegalArgumentException ex) { + String code = ex.getMessage(); + int status = "USER_NOT_FOUND".equals(code) ? 404 : WireCodes.Status.UNVERIFIED; + return NetExceptionResponseFactory.error(req, status, code, "Сообщение не прошло проверку"); + } + + SignedMessageV2Entry entry = SignedMessagesCore.toEntry(block, "DeleteMessage", null); + SignedMessagesV2DAO.ApplyStatus status = SignedMessagesV2DAO.getInstance().applyDeleteMessage(entry); + + SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); + if (status.applied()) { + counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, block); + } + if (status.applied() && ctx != null && ctx.isAuthenticatedUser()) { + DmFederationService.fanOutDeleteMessage(block.fromLogin, block.toLogin, block.messageType, req.getBlobB64().trim()); + } + + Net_DeleteMessage_Response resp = new Net_DeleteMessage_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setMessageKey(entry.getMessageKey()); + resp.setBaseKey(entry.getBaseKey()); + resp.setDeliveredWsSessions(counters.wsDelivered); + resp.setDeliveredWebPushSessions(counters.pushDelivered); + return resp; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index a1f9faf..1f87ffd 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -11,6 +11,8 @@ import server.logic.ws_protocol.WireCodes; import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; +import java.util.Base64; + public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { @@ -25,7 +27,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { } catch (IllegalArgumentException ex) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный формат входящего блока"); } - if (!incoming.isIncomingType()) { + if (incoming.messageType != SignedMessageBlock.TYPE_INCOMING_TEXT + && incoming.messageType != SignedMessageBlock.TYPE_READ_INCOMING) { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_MESSAGE_TYPE", "API принимает только входящие типы 1/3"); } @@ -44,10 +47,15 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - boolean inserted = SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); + SignedMessagesV2DAO.ApplyStatus status = incoming.isContentType() + ? SignedMessagesV2DAO.getInstance().upsertIncomingCopy(entry) + : SignedMessagesV2DAO.getInstance().insertIfAbsent(entry); SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); - if (inserted) { - counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null); + if (status.applied()) { + counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming); + } + if (status == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { + bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); } Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response(); @@ -64,4 +72,15 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { private boolean isBlank(String s) { return s == null || s.isBlank(); } + + private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception { + SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); + if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return; + server.sync.DmFederationService.fanOutDeleteConversation( + tombstone.getFromLogin(), + tombstone.getToLogin(), + tombstone.getMessageType(), + Base64.getEncoder().encodeToString(tombstone.getRawBlock()) + ); + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index db0f9e8..d1baee6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -8,9 +8,12 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Reque import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; +import server.sync.DmFederationService; import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.SignedMessageV2Entry; +import java.util.Base64; + public class Net_SendMessagePair_Handler implements JsonMessageHandler { @Override public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { @@ -41,7 +44,7 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessageV2Entry incomingEntry; SignedMessageV2Entry outgoingEntry; try { - String sourceApi = "ReceiveOutcomingMessage"; + String sourceApi = "SendMessagePair"; String originSessionId = (ctx != null && !isBlank(ctx.getSessionId())) ? ctx.getSessionId() : null; incomingEntry = SignedMessagesCore.toEntry(incoming, sourceApi, originSessionId); outgoingEntry = SignedMessagesCore.toEntry(outgoing, sourceApi, originSessionId); @@ -49,18 +52,20 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения"); } - boolean pairInserted; + SignedMessagesV2DAO.ApplyStatus pairStatus; if (incoming.isContentType()) { - pairInserted = SignedMessagesV2DAO.getInstance().upsertContentPair( + pairStatus = SignedMessagesV2DAO.getInstance().upsertContentPair( incomingEntry, outgoingEntry ); } else { - pairInserted = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry); + pairStatus = SignedMessagesV2DAO.getInstance().insertPairBothOrNothing(incomingEntry, outgoingEntry) + ? SignedMessagesV2DAO.ApplyStatus.APPLIED + : SignedMessagesV2DAO.ApplyStatus.DUPLICATE_OR_OLDER; } SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters(); - if (pairInserted) { - inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, incoming); + if (pairStatus.applied()) { + inCounters = SignedMessagesRealtime.deliverToRelevantSessions(incomingEntry, incoming); } String excludeSessionId = null; @@ -68,8 +73,21 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { excludeSessionId = ctx.getSessionId(); } SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters(); - if (pairInserted) { - outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, outgoing, excludeSessionId); + if (pairStatus.applied()) { + outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId); + } + + if (pairStatus == SignedMessagesV2DAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { + bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); + } + + if (pairStatus.applied() && ctx != null && ctx.isAuthenticatedUser()) { + DmFederationService.fanOutPair( + incoming.fromLogin, + incoming.toLogin, + req.getIncomingBlobB64().trim(), + req.getOutgoingBlobB64().trim() + ); } Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response(); @@ -87,4 +105,15 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { private boolean isBlank(String s) { return s == null || s.isBlank(); } + + private void bounceConversationDeleteIfKnown(String fromLogin, String toLogin) throws Exception { + SignedMessageV2Entry tombstone = SignedMessagesV2DAO.getInstance().getLatestConversationDelete(fromLogin, toLogin); + if (tombstone == null || tombstone.getRawBlock() == null || tombstone.getRawBlock().length == 0) return; + DmFederationService.fanOutDeleteConversation( + tombstone.getFromLogin(), + tombstone.getToLogin(), + tombstone.getMessageType(), + Base64.getEncoder().encodeToString(tombstone.getRawBlock()) + ); + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java new file mode 100644 index 0000000..6a751cd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendSignal_Handler.java @@ -0,0 +1,220 @@ +package server.logic.ws_protocol.JSON.messages; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request; +import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.ActiveSessionEntry; +import shine.db.entities.SolanaUserEntry; +import utils.crypto.Ed25519Util; + +import java.security.MessageDigest; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +public class Net_SendSignal_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_SendSignal_Handler.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String TARGET_MODE_SINGLE = "single_session"; + private static final String TARGET_MODE_ALL = "all_sessions"; + private static final long ALLOWED_SKEW_MS = 30_000L; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception { + Net_SendSignal_Request req = (Net_SendSignal_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getActiveSession() == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + + String fromLogin = safe(ctx.getLogin()); + String fromSessionId = safe(ctx.getSessionId()); + ActiveSessionEntry activeSession = ctx.getActiveSession(); + + String toRequest = safe(req.getToLogin()); + String targetMode = safe(req.getTargetMode()).toLowerCase(); + String targetSessionId = safe(req.getTargetSessionId()); + String signalType = safe(req.getSignalType()); + String signalRequestId = safe(req.getSignalRequestId()); + String data = req.getData() == null ? "" : req.getData(); + Long timeMs = req.getTimeMs(); + String sessionSignatureB64 = safe(req.getSessionSignatureB64()); + String clientSignatureB64 = safe(req.getClientSignatureB64()); + + if (toRequest.isBlank() || signalType.isBlank() || signalRequestId.isBlank() || timeMs == null || sessionSignatureB64.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/targetMode/signalType/signalRequestId/timeMs/sessionSignatureB64 обязательны"); + } + if (!TARGET_MODE_SINGLE.equals(targetMode) && !TARGET_MODE_ALL.equals(targetMode)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_TARGET_MODE", "Поддерживаются targetMode=single_session или all_sessions"); + } + if (TARGET_MODE_SINGLE.equals(targetMode) && targetSessionId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Для single_session нужен targetSessionId"); + } + if (TARGET_MODE_ALL.equals(targetMode) && !targetSessionId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Для all_sessions нельзя передавать targetSessionId"); + } + + long nowMs = System.currentTimeMillis(); + if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "TIME_SKEW", "Время клиента отличается от сервера более чем на 30 секунд"); + } + + SolanaUserEntry senderUser = ctx.getSolanaUser(); + if (senderUser == null || senderUser.getClientKey() == null || senderUser.getClientKey().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "NO_CLIENT_KEY", "Для пользователя не найден client key"); + } + + SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest); + if (targetUser == null) { + return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден"); + } + String toLogin = safe(targetUser.getLogin()); + + String digestB64; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digestB64 = Base64Ws.encode(digest.digest(data.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + log.warn("SendSignal: failed to hash signal data (fromLogin={}, signalType={})", fromLogin, signalType, e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_DATA", "Не удалось обработать data"); + } + + String sessionPreimage = buildSessionPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64); + if (!verifySignature(activeSession.getSessionKey(), sessionPreimage, sessionSignatureB64, "sessionKey")) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key"); + } + + if (!clientSignatureB64.isBlank()) { + String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64); + if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_CLIENT_SIGNATURE", "Некорректная подпись client key"); + } + } + + List targets = resolveTargets(targetMode, toLogin, targetSessionId); + if (targets.isEmpty()) { + String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS"; + String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала"; + return NetExceptionResponseFactory.error(req, 404, code, msg); + } + + List deliveredSessionIds = new ArrayList<>(); + for (ConnectionContext targetCtx : targets) { + String eventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt"); + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("eventId", eventId); + payload.put("fromLogin", fromLogin); + payload.put("fromSessionId", fromSessionId); + payload.put("toLogin", toLogin); + payload.put("targetMode", targetMode); + payload.put("targetSessionId", safe(targetCtx.getSessionId())); + payload.put("signalType", signalType); + payload.put("signalRequestId", signalRequestId); + payload.put("data", data); + payload.put("timeMs", timeMs); + payload.put("sessionSignatureB64", sessionSignatureB64); + payload.put("clientSignatureB64", clientSignatureB64); + payload.put("dataSha256B64", digestB64); + if (WsEventSender.sendEvent(targetCtx, "IncomingSignal", eventId, payload)) { + deliveredSessionIds.add(safe(targetCtx.getSessionId())); + } + } + + if (deliveredSessionIds.isEmpty()) { + return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию"); + } + + Net_SendSignal_Response resp = new Net_SendSignal_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setDeliveredCount(deliveredSessionIds.size()); + resp.setDeliveredSessionIds(deliveredSessionIds); + return resp; + } + + private static String buildSessionPreimage(String fromLogin, + String fromSessionId, + String toLogin, + String targetMode, + String targetSessionId, + String signalType, + String signalRequestId, + long timeMs, + String dataSha256B64) { + return "SEND_SIGNAL_SESSION:" + + fromLogin + ":" + + fromSessionId + ":" + + toLogin + ":" + + targetMode + ":" + + targetSessionId + ":" + + signalType + ":" + + signalRequestId + ":" + + timeMs + ":" + + dataSha256B64; + } + + private static String buildClientPreimage(String fromLogin, + String fromSessionId, + String toLogin, + String targetMode, + String targetSessionId, + String signalType, + String signalRequestId, + long timeMs, + String dataSha256B64) { + return "SEND_SIGNAL_CLIENT:" + + fromLogin + ":" + + fromSessionId + ":" + + toLogin + ":" + + targetMode + ":" + + targetSessionId + ":" + + signalType + ":" + + signalRequestId + ":" + + timeMs + ":" + + dataSha256B64; + } + + private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception { + byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName); + byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); + return Ed25519Util.verify(preimage.getBytes(StandardCharsets.UTF_8), signature64, publicKey32); + } + + private static List resolveTargets(String targetMode, String toLogin, String targetSessionId) { + List targets = new ArrayList<>(); + if (TARGET_MODE_SINGLE.equals(targetMode)) { + ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId); + if (targetCtx != null && toLogin.equalsIgnoreCase(safe(targetCtx.getLogin()))) { + targets.add(targetCtx); + } + return targets; + } + + Set onlineTargets = ActiveConnectionsRegistry.getInstance().getByLogin(toLogin); + onlineTargets.stream() + .filter(item -> item != null && item.getWsSession() != null && item.getWsSession().isOpen()) + .sorted(Comparator.comparing(ConnectionContext::getSessionId, Comparator.nullsLast(String::compareTo))) + .forEach(targets::add); + return targets; + } + + private static String safe(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java index 9808453..fd7c16f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/ReadReceiptPayload.java @@ -9,18 +9,16 @@ final class ReadReceiptPayload { final String refFromLogin; final long refTimeMs; final long refNonce; - final int refType; - private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce, int refType) { + private ReadReceiptPayload(String refToLogin, String refFromLogin, long refTimeMs, long refNonce) { this.refToLogin = refToLogin; this.refFromLogin = refFromLogin; this.refTimeMs = refTimeMs; this.refNonce = refNonce; - this.refType = refType; } static ReadReceiptPayload parse(byte[] payload) { - if (payload == null || payload.length < 1 + 1 + 8 + 4 + 2) { + if (payload == null || payload.length < 1 + 1 + 8 + 4) { throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN"); } ByteBuffer bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN); @@ -29,14 +27,10 @@ final class ReadReceiptPayload { long refTimeMs = bb.getLong(); if (refTimeMs < 0) throw new IllegalArgumentException("BAD_RECEIPT_TIME"); long refNonce = Integer.toUnsignedLong(bb.getInt()); - int refType = Short.toUnsignedInt(bb.getShort()); - if (refType < SignedMessageBlock.TYPE_INCOMING_TEXT || refType > SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { - throw new IllegalArgumentException("BAD_RECEIPT_REF_TYPE"); - } if (bb.hasRemaining()) { throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN"); } - return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce, refType); + return new ReadReceiptPayload(refTo, refFrom, refTimeMs, refNonce); } String refBaseKey() { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java index 4528abf..9b975fa 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessageBlock.java @@ -6,12 +6,16 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; final class SignedMessageBlock { - static final byte[] LEGACY_PREFIX = "SHiNE_dm2".getBytes(StandardCharsets.US_ASCII); - static final byte[] V1_PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII); + static final byte[] PREFIX = "SHiNE_DM".getBytes(StandardCharsets.US_ASCII); + static final int TYPE_INCOMING_TEXT = 1; static final int TYPE_OUTGOING_COPY = 2; static final int TYPE_READ_INCOMING = 3; static final int TYPE_READ_OUTGOING_COPY = 4; + static final int TYPE_MESSAGE_DELETED_BY_SENDER = 5; + static final int TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6; + static final int TYPE_CONVERSATION_DELETED_BY_SENDER = 7; + static final int TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8; final String toLogin; final String fromLogin; @@ -19,14 +23,15 @@ final class SignedMessageBlock { final long nonce; final int messageType; final long revisionTimeMs; + final long reencryptedAtMs; final int formatVersionMajor; final int formatVersionMinor; - final byte[] payloadBytes; - final byte[] encryptedBodyBytes; + final byte[] bodyBytes; + final EncryptedBodyPayload encryptedBody; + final ReadReceiptPayload readReceiptBody; final byte[] signedBody; final byte[] signature64; final byte[] rawPacket; - final boolean legacyFormat; private SignedMessageBlock( String toLogin, @@ -35,14 +40,15 @@ final class SignedMessageBlock { long nonce, int messageType, long revisionTimeMs, + long reencryptedAtMs, int formatVersionMajor, int formatVersionMinor, - byte[] payloadBytes, - byte[] encryptedBodyBytes, + byte[] bodyBytes, + EncryptedBodyPayload encryptedBody, + ReadReceiptPayload readReceiptBody, byte[] signedBody, byte[] signature64, - byte[] rawPacket, - boolean legacyFormat + byte[] rawPacket ) { this.toLogin = toLogin; this.fromLogin = fromLogin; @@ -50,85 +56,27 @@ final class SignedMessageBlock { this.nonce = nonce; this.messageType = messageType; this.revisionTimeMs = revisionTimeMs; + this.reencryptedAtMs = reencryptedAtMs; this.formatVersionMajor = formatVersionMajor; this.formatVersionMinor = formatVersionMinor; - this.payloadBytes = payloadBytes; - this.encryptedBodyBytes = encryptedBodyBytes; + this.bodyBytes = bodyBytes; + this.encryptedBody = encryptedBody; + this.readReceiptBody = readReceiptBody; this.signedBody = signedBody; this.signature64 = signature64; this.rawPacket = rawPacket; - this.legacyFormat = legacyFormat; } - static SignedMessageBlock parse(byte[] raw, int maxEncryptedBodyBytes) { - if (raw == null || raw.length < 64) { + static SignedMessageBlock parse(byte[] raw, int maxBodyBytes) { + if (raw == null || raw.length < PREFIX.length + 2 + 1 + 1 + 8 + 4 + 1 + 8 + 8 + 4 + 64) { throw new IllegalArgumentException("BAD_LEN"); } + if (!startsWith(raw, PREFIX)) { + throw new IllegalArgumentException("BAD_PREFIX"); + } - if (startsWith(raw, LEGACY_PREFIX)) { - return parseLegacy(raw, maxEncryptedBodyBytes); - } - if (startsWith(raw, V1_PREFIX)) { - return parseV1(raw, maxEncryptedBodyBytes); - } - throw new IllegalArgumentException("BAD_PREFIX"); - } - - private static SignedMessageBlock parseLegacy(byte[] raw, int maxPayloadBytes) { - if (raw.length < LEGACY_PREFIX.length + 1 + 1 + 8 + 4 + 2 + 2 + 64) { - throw new IllegalArgumentException("BAD_LEN"); - } ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); - bb.position(LEGACY_PREFIX.length); - - String toLogin = readAscii(bb, 1, 60, "BAD_TO_LOGIN"); - String fromLogin = readAscii(bb, 1, 60, "BAD_FROM_LOGIN"); - - long timeMs = bb.getLong(); - if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME"); - - long nonce = Integer.toUnsignedLong(bb.getInt()); - int messageType = Short.toUnsignedInt(bb.getShort()); - ensureMessageType(messageType); - - int payloadLen = Short.toUnsignedInt(bb.getShort()); - if (payloadLen < 1 || payloadLen > maxPayloadBytes) { - throw new IllegalArgumentException("BAD_MESSAGE_LEN"); - } - if (bb.remaining() != payloadLen + 64) { - throw new IllegalArgumentException("BAD_LEN"); - } - - byte[] payload = new byte[payloadLen]; - bb.get(payload); - byte[] signature64 = new byte[64]; - bb.get(signature64); - byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); - - return new SignedMessageBlock( - toLogin, - fromLogin, - timeMs, - nonce, - messageType, - 0L, - 2, - 0, - payload, - payload, - signedBody, - signature64, - raw, - true - ); - } - - private static SignedMessageBlock parseV1(byte[] raw, int maxEncryptedBodyBytes) { - if (raw.length < V1_PREFIX.length + 2 + 1 + 1 + 8 + 4 + 2 + 8 + 1 + 4 + 64) { - throw new IllegalArgumentException("BAD_LEN"); - } - ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); - bb.position(V1_PREFIX.length); + bb.position(PREFIX.length); int major = Byte.toUnsignedInt(bb.get()); int minor = Byte.toUnsignedInt(bb.get()); @@ -141,32 +89,48 @@ final class SignedMessageBlock { long timeMs = bb.getLong(); if (timeMs < 0) throw new IllegalArgumentException("BAD_TIME"); long nonce = Integer.toUnsignedLong(bb.getInt()); - int messageType = Short.toUnsignedInt(bb.getShort()); + int messageType = Byte.toUnsignedInt(bb.get()); ensureMessageType(messageType); long revisionTimeMs = bb.getLong(); if (revisionTimeMs < 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); + long reencryptedAtMs = bb.getLong(); + if (reencryptedAtMs < 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); - int attachmentsCount = Byte.toUnsignedInt(bb.get()); - if (attachmentsCount != 0) { - throw new IllegalArgumentException("ATTACHMENTS_DISABLED"); - } - - if (bb.remaining() < 4 + 64) { - throw new IllegalArgumentException("BAD_LEN"); - } - long encryptedBodyLen = Integer.toUnsignedLong(bb.getInt()); - if (encryptedBodyLen > maxEncryptedBodyBytes) { + long bodyLen = Integer.toUnsignedLong(bb.getInt()); + if (bodyLen > maxBodyBytes) { throw new IllegalArgumentException("BAD_MESSAGE_LEN"); } - if (bb.remaining() != encryptedBodyLen + 64) { + if (bb.remaining() != bodyLen + 64) { throw new IllegalArgumentException("BAD_LEN"); } - byte[] encryptedBody = new byte[(int) encryptedBodyLen]; - bb.get(encryptedBody); + + byte[] bodyBytes = new byte[(int) bodyLen]; + bb.get(bodyBytes); byte[] signature64 = new byte[64]; bb.get(signature64); byte[] signedBody = Arrays.copyOf(raw, raw.length - 64); + EncryptedBodyPayload encryptedBody = null; + ReadReceiptPayload readReceiptBody = null; + + if (messageType == TYPE_INCOMING_TEXT || messageType == TYPE_OUTGOING_COPY) { + if (bodyBytes.length == 0) throw new IllegalArgumentException("BAD_MESSAGE_LEN"); + } else if (messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY) { + if (bodyBytes.length == 0) throw new IllegalArgumentException("BAD_RECEIPT_PAYLOAD_LEN"); + if (revisionTimeMs != 0 || reencryptedAtMs != 0) { + throw new IllegalArgumentException("BAD_RECEIPT_REVISION"); + } + readReceiptBody = ReadReceiptPayload.parse(bodyBytes); + } else if (messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT) { + if (bodyBytes.length != 0) throw new IllegalArgumentException("BAD_DELETE_BODY"); + if (revisionTimeMs <= 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); + if (reencryptedAtMs != 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); + } else if (messageType == TYPE_CONVERSATION_DELETED_BY_SENDER || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { + if (bodyBytes.length != 0) throw new IllegalArgumentException("BAD_DELETE_BODY"); + if (revisionTimeMs != 0) throw new IllegalArgumentException("BAD_REVISION_TIME"); + if (reencryptedAtMs != 0) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); + } + return new SignedMessageBlock( toLogin, fromLogin, @@ -174,23 +138,24 @@ final class SignedMessageBlock { nonce, messageType, revisionTimeMs, + reencryptedAtMs, major, minor, + bodyBytes, encryptedBody, - encryptedBody, + readReceiptBody, signedBody, signature64, - raw, - false + raw ); } - boolean isIncomingType() { - return messageType == TYPE_INCOMING_TEXT || messageType == TYPE_READ_INCOMING; + boolean isIncomingCopyType() { + return messageType == TYPE_INCOMING_TEXT; } boolean isOutgoingCopyType() { - return messageType == TYPE_OUTGOING_COPY || messageType == TYPE_READ_OUTGOING_COPY; + return messageType == TYPE_OUTGOING_COPY; } boolean isContentType() { @@ -201,16 +166,25 @@ final class SignedMessageBlock { return messageType == TYPE_READ_INCOMING || messageType == TYPE_READ_OUTGOING_COPY; } - boolean isDeletedContent() { - return isContentType() && !legacyFormat && encryptedBodyBytes.length == 0; + boolean isMessageDeleteType() { + return messageType == TYPE_MESSAGE_DELETED_BY_SENDER || messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT; } - String targetLogin() { - return isIncomingType() ? toLogin : fromLogin; + boolean isConversationDeleteType() { + return messageType == TYPE_CONVERSATION_DELETED_BY_SENDER || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT; + } + + boolean isDeleteType() { + return isMessageDeleteType() || isConversationDeleteType(); + } + + boolean isSignedByRecipient() { + return messageType == TYPE_MESSAGE_DELETED_BY_RECIPIENT + || messageType == TYPE_CONVERSATION_DELETED_BY_RECIPIENT; } private static void ensureMessageType(int messageType) { - if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_READ_OUTGOING_COPY) { + if (messageType < TYPE_INCOMING_TEXT || messageType > TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { throw new IllegalArgumentException("BAD_MESSAGE_TYPE"); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java index 0030ba7..f51c2eb 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesCore.java @@ -1,30 +1,29 @@ package server.logic.ws_protocol.JSON.messages; +import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; import shine.db.dao.SolanaUsersDAO; import shine.db.entities.SignedMessageV2Entry; import shine.db.entities.SolanaUserEntry; import utils.crypto.Ed25519Util; -import java.nio.charset.StandardCharsets; import java.util.Base64; final class SignedMessagesCore { - private static final int MAX_ENCRYPTED_BODY_BYTES = 16384; + private static final int MAX_BODY_BYTES = 16384; private SignedMessagesCore() {} static SignedMessageBlock parseFromB64(String blobB64) { try { byte[] raw = Base64.getDecoder().decode(blobB64.trim()); - return SignedMessageBlock.parse(raw, MAX_ENCRYPTED_BODY_BYTES); + return SignedMessageBlock.parse(raw, MAX_BODY_BYTES); } catch (IllegalArgumentException e) { String code = e.getMessage(); if (code == null || code.isBlank()) { throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); } switch (code) { - case "ATTACHMENTS_DISABLED", - "BAD_PREFIX", + case "BAD_PREFIX", "BAD_LEN", "BAD_TO_LOGIN", "BAD_FROM_LOGIN", @@ -32,24 +31,43 @@ final class SignedMessagesCore { "BAD_MESSAGE_TYPE", "BAD_MESSAGE_LEN", "BAD_FORMAT_VERSION", - "BAD_REVISION_TIME" -> throw e; + "BAD_REVISION_TIME", + "BAD_REENCRYPTED_TIME", + "BAD_DELETE_BODY", + "BAD_RECEIPT_PAYLOAD_LEN", + "BAD_RECEIPT_TO_LOGIN", + "BAD_RECEIPT_FROM_LOGIN", + "BAD_RECEIPT_TIME", + "BAD_RECEIPT_REVISION", + "BAD_ENCRYPTED_BODY", + "BAD_CRYPTO_METHOD" -> throw e; default -> throw new IllegalArgumentException("BAD_BLOCK_FORMAT"); } } } static void verifyUsersAndSignature(SignedMessageBlock block) throws Exception { - SolanaUserEntry from = SolanaUsersDAO.getInstance().getByLogin(block.fromLogin); - SolanaUserEntry to = SolanaUsersDAO.getInstance().getByLogin(block.toLogin); + SolanaUserEntry from = resolveUser(block.fromLogin); + SolanaUserEntry to = resolveUser(block.toLogin); if (from == null || to == null) { throw new IllegalArgumentException("USER_NOT_FOUND"); } - byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getClientKey()); + + String signerLogin = block.isSignedByRecipient() ? block.toLogin : block.fromLogin; + SolanaUserEntry signer = signerLogin.equalsIgnoreCase(block.fromLogin) ? from : to; + byte[] pubKey32 = Ed25519Util.keyFromBase64(signer.getClientKey()); if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) { throw new IllegalArgumentException("BAD_SIGNATURE"); } } + private static SolanaUserEntry resolveUser(String login) throws Exception { + SolanaUsersDAO usersDao = SolanaUsersDAO.getInstance(); + SolanaUserEntry user = usersDao.getByLogin(login); + if (user != null) return user; + return SolanaUserPdaImportService.findOrImportByLogin(login); + } + static void validatePair(SignedMessageBlock incoming, SignedMessageBlock outgoing) { if (incoming.messageType % 2 == 0) throw new IllegalArgumentException("BAD_PAIR_TYPES"); if (outgoing.messageType != incoming.messageType + 1) throw new IllegalArgumentException("BAD_PAIR_TYPES"); @@ -57,38 +75,28 @@ final class SignedMessagesCore { if (!incoming.fromLogin.equalsIgnoreCase(outgoing.fromLogin)) throw new IllegalArgumentException("BAD_PAIR_KEYS"); if (incoming.timeMs != outgoing.timeMs) throw new IllegalArgumentException("BAD_PAIR_KEYS"); if (incoming.nonce != outgoing.nonce) throw new IllegalArgumentException("BAD_PAIR_KEYS"); - - if (incoming.isReadReceiptType()) { - ReadReceiptPayload inRef = ReadReceiptPayload.parse(incoming.payloadBytes); - ReadReceiptPayload outRef = ReadReceiptPayload.parse(outgoing.payloadBytes); - if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin) - || !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin) - || inRef.refTimeMs != outRef.refTimeMs - || inRef.refNonce != outRef.refNonce - || inRef.refType != outRef.refType) { - throw new IllegalArgumentException("BAD_RECEIPT_REF"); - } - return; - } - - if (incoming.legacyFormat || outgoing.legacyFormat) { - throw new IllegalArgumentException("BAD_CONTENT_FORMAT"); - } - if (incoming.revisionTimeMs != outgoing.revisionTimeMs) { - throw new IllegalArgumentException("BAD_REVISION_TIME"); - } + if (incoming.revisionTimeMs != outgoing.revisionTimeMs) throw new IllegalArgumentException("BAD_REVISION_TIME"); + if (incoming.reencryptedAtMs != outgoing.reencryptedAtMs) throw new IllegalArgumentException("BAD_REENCRYPTED_TIME"); if (incoming.formatVersionMajor != outgoing.formatVersionMajor || incoming.formatVersionMinor != outgoing.formatVersionMinor) { throw new IllegalArgumentException("BAD_FORMAT_VERSION"); } - if (incoming.encryptedBodyBytes.length != outgoing.encryptedBodyBytes.length) { - throw new IllegalArgumentException("BAD_MESSAGE_LEN"); - } - for (int i = 0; i < incoming.encryptedBodyBytes.length; i++) { - if (incoming.encryptedBodyBytes[i] != outgoing.encryptedBodyBytes[i]) { - throw new IllegalArgumentException("BAD_ENCRYPTED_BODY"); + if (incoming.isReadReceiptType()) { + if (!outgoing.isReadReceiptType()) throw new IllegalArgumentException("BAD_PAIR_TYPES"); + ReadReceiptPayload inRef = incoming.readReceiptBody; + ReadReceiptPayload outRef = outgoing.readReceiptBody; + if (!inRef.refToLogin.equalsIgnoreCase(outRef.refToLogin) + || !inRef.refFromLogin.equalsIgnoreCase(outRef.refFromLogin) + || inRef.refTimeMs != outRef.refTimeMs + || inRef.refNonce != outRef.refNonce) { + throw new IllegalArgumentException("BAD_RECEIPT_REF"); } + return; + } + + if (!incoming.isContentType() || !outgoing.isContentType()) { + throw new IllegalArgumentException("BAD_PAIR_TYPES"); } } @@ -99,32 +107,40 @@ final class SignedMessagesCore { SignedMessageV2Entry entry = new SignedMessageV2Entry(); entry.setMessageKey(messageKey); entry.setBaseKey(baseKey); - entry.setTargetLogin(block.targetLogin()); + entry.setTargetLogin(primaryTargetLogin(block)); entry.setFromLogin(block.fromLogin); entry.setToLogin(block.toLogin); entry.setTimeMs(block.timeMs); entry.setNonce(block.nonce); entry.setMessageType(block.messageType); entry.setRevisionTimeMs(block.revisionTimeMs); + entry.setReencryptedAtMs(block.reencryptedAtMs); entry.setRawBlock(block.rawPacket); entry.setCreatedAtMs(System.currentTimeMillis()); entry.setSourceApi(sourceApi); entry.setOriginSessionId(originSessionId); - if (block.messageType == SignedMessageBlock.TYPE_READ_INCOMING - || block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { - ReadReceiptPayload ref = ReadReceiptPayload.parse(block.payloadBytes); + if (block.isReadReceiptType()) { + ReadReceiptPayload ref = block.readReceiptBody; entry.setReceiptRefBaseKey(ref.refBaseKey()); - entry.setReceiptRefType(ref.refType); } return entry; } static String previewTextForPush(SignedMessageBlock block) { - if (!block.isContentType() || block.encryptedBodyBytes == null || block.encryptedBodyBytes.length == 0) { - return ""; + return ""; + } + + private static String primaryTargetLogin(SignedMessageBlock block) { + if (block.messageType == SignedMessageBlock.TYPE_INCOMING_TEXT + || block.messageType == SignedMessageBlock.TYPE_READ_INCOMING) { + return block.toLogin; } - return new String(block.encryptedBodyBytes, StandardCharsets.UTF_8); + if (block.messageType == SignedMessageBlock.TYPE_OUTGOING_COPY + || block.messageType == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { + return block.fromLogin; + } + return block.toLogin; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index ee344a4..b37804a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -13,43 +13,58 @@ import shine.db.dao.SignedMessagesV2DAO; import shine.db.entities.ActiveSessionEntry; import shine.db.entities.SignedMessageV2Entry; +import java.util.ArrayList; import java.util.Base64; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; public final class SignedMessagesRealtime { private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class); private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ScheduledExecutorService BACKLOG_EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r, "signed-messages-backlog"); + thread.setDaemon(true); + return thread; + } + }); + private SignedMessagesRealtime() {} - static DeliveryCounters deliverToTargetSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception { - return deliverToTargetSessions(message, block, null); + static DeliveryCounters deliverToRelevantSessions(SignedMessageV2Entry message, SignedMessageBlock block) throws Exception { + return deliverToRelevantSessions(message, block, null); } - static DeliveryCounters deliverToTargetSessions( + static DeliveryCounters deliverToRelevantSessions( SignedMessageV2Entry message, SignedMessageBlock block, String excludeSessionId ) throws Exception { DeliveryCounters counters = new DeliveryCounters(); - List sessions = ActiveSessionsDAO.getInstance().getByLogin(message.getTargetLogin()); long now = System.currentTimeMillis(); - for (ActiveSessionEntry s : sessions) { - String sessionId = s.getSessionId(); - if (excludeSessionId != null && excludeSessionId.equals(sessionId)) { - continue; - } - SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now); - boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, message, false); - if (deliveredOnline) { - counters.wsDelivered++; - continue; - } - if (message.getMessageType() == SignedMessageBlock.TYPE_INCOMING_TEXT - && block != null - && block.revisionTimeMs == 0 - && !block.isDeletedContent()) { - boolean pushed = pushNewMessageNotification(s, message, block); - if (pushed) counters.pushDelivered++; + for (String targetLogin : targetLoginsForMessage(message)) { + List sessions = ActiveSessionsDAO.getInstance().getByLogin(targetLogin); + for (ActiveSessionEntry s : sessions) { + String sessionId = s.getSessionId(); + if (excludeSessionId != null && excludeSessionId.equals(sessionId)) { + continue; + } + SignedMessagesV2DAO.getInstance().ensureDeliveryRow(message.getMessageKey(), sessionId, now); + boolean deliveredOnline = sendEventToSessionIfOnline(sessionId, targetLogin, message, false); + if (deliveredOnline) { + counters.wsDelivered++; + continue; + } + if (shouldPushNewIncomingMessage(targetLogin, message, block)) { + boolean pushed = pushNewMessageNotification(s, message); + if (pushed) counters.pushDelivered++; + } } } return counters; @@ -60,19 +75,36 @@ public final class SignedMessagesRealtime { String login = ctx.getLogin(); String sessionId = ctx.getSessionId(); if (isBlank(login) || isBlank(sessionId)) return; + dispatchPendingForSession(login, sessionId); + } + public static void dispatchPendingForSessionAsync(ConnectionContext ctx, long delayMs) { + if (ctx == null || !ctx.isAuthenticatedUser()) return; + String login = safeLower(ctx.getLogin()); + String sessionId = String.valueOf(ctx.getSessionId() == null ? "" : ctx.getSessionId()).trim(); + if (isBlank(login) || isBlank(sessionId)) return; + long safeDelayMs = Math.max(0L, delayMs); + BACKLOG_EXECUTOR.schedule(() -> dispatchPendingForSession(login, sessionId), safeDelayMs, TimeUnit.MILLISECONDS); + } + + private static void dispatchPendingForSession(String login, String sessionId) { try { List pending = SignedMessagesV2DAO.getInstance() .listPendingForSession(login, sessionId); for (SignedMessageV2Entry e : pending) { - sendEventToSessionIfOnline(sessionId, e, true); + sendEventToSessionIfOnline(sessionId, login, e, true); } } catch (Exception e) { log.warn("Failed to dispatch pending messages for sessionId={}", sessionId, e); } } - private static boolean sendEventToSessionIfOnline(String sessionId, SignedMessageV2Entry message, boolean backlog) { + private static boolean sendEventToSessionIfOnline( + String sessionId, + String actualTargetLogin, + SignedMessageV2Entry message, + boolean backlog + ) { ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(sessionId); if (targetCtx == null) return false; @@ -82,7 +114,7 @@ public final class SignedMessagesRealtime { payload.put("baseKey", message.getBaseKey()); payload.put("fromLogin", message.getFromLogin()); payload.put("toLogin", message.getToLogin()); - payload.put("targetLogin", message.getTargetLogin()); + payload.put("targetLogin", actualTargetLogin); payload.put("messageType", message.getMessageType()); payload.put("timeMs", message.getTimeMs()); payload.put("nonce", message.getNonce()); @@ -91,27 +123,23 @@ public final class SignedMessagesRealtime { if (message.getReceiptRefBaseKey() != null) { payload.put("receiptRefBaseKey", message.getReceiptRefBaseKey()); } - if (message.getReceiptRefType() != null) { - payload.put("receiptRefType", message.getReceiptRefType()); - } return WsEventSender.sendEvent(targetCtx, "SignedMessageArrived", message.getMessageKey(), payload); } - private static boolean pushNewMessageNotification( - ActiveSessionEntry session, - SignedMessageV2Entry message, - SignedMessageBlock block - ) { + private static boolean shouldPushNewIncomingMessage(String targetLogin, SignedMessageV2Entry message, SignedMessageBlock block) { + if (block == null) return false; + if (message.getMessageType() != SignedMessageBlock.TYPE_INCOMING_TEXT) return false; + if (!targetLogin.equalsIgnoreCase(message.getToLogin())) return false; + return block.revisionTimeMs == 0; + } + + private static boolean pushNewMessageNotification(ActiveSessionEntry session, SignedMessageV2Entry message) { try { if (session == null) return false; if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { return false; } - String preview = SignedMessagesCore.previewTextForPush(block).replace('\n', ' ').trim(); - if (preview.length() > 80) preview = preview.substring(0, 80) + "..."; - String text = preview.isBlank() - ? "Вам пришло сообщение от " + message.getFromLogin() + ". Откройте для прочтения." - : preview; + String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + "."; String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}"; return WebPushSender.sendBase64Payload( session.getPushEndpoint(), @@ -124,6 +152,28 @@ public final class SignedMessagesRealtime { } } + private static List targetLoginsForMessage(SignedMessageV2Entry message) { + Set out = new LinkedHashSet<>(); + int type = message.getMessageType(); + if (type == SignedMessageBlock.TYPE_INCOMING_TEXT || type == SignedMessageBlock.TYPE_READ_INCOMING) { + out.add(safeLower(message.getToLogin())); + } else if (type == SignedMessageBlock.TYPE_OUTGOING_COPY || type == SignedMessageBlock.TYPE_READ_OUTGOING_COPY) { + out.add(safeLower(message.getFromLogin())); + } else if (type >= SignedMessageBlock.TYPE_MESSAGE_DELETED_BY_SENDER + && type <= SignedMessageBlock.TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { + out.add(safeLower(message.getFromLogin())); + out.add(safeLower(message.getToLogin())); + } else if (!isBlank(message.getTargetLogin())) { + out.add(safeLower(message.getTargetLogin())); + } + out.remove(""); + return new ArrayList<>(out); + } + + private static String safeLower(String s) { + return s == null ? "" : s.trim().toLowerCase(); + } + private static String jsonEscape(String s) { if (s == null) return ""; StringBuilder out = new StringBuilder(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Request.java new file mode 100644 index 0000000..efe87f4 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_DeleteConversation_Request extends Net_Request { + private String blobB64; + + public String getBlobB64() { return blobB64; } + public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Response.java new file mode 100644 index 0000000..634752b --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteConversation_Response.java @@ -0,0 +1,19 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_DeleteConversation_Response extends Net_Response { + private String messageKey; + private String baseKey; + private int deliveredWsSessions; + private int deliveredWebPushSessions; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public int getDeliveredWsSessions() { return deliveredWsSessions; } + public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } + public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; } + public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Request.java new file mode 100644 index 0000000..4c51b5f --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Request.java @@ -0,0 +1,10 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_DeleteMessage_Request extends Net_Request { + private String blobB64; + + public String getBlobB64() { return blobB64; } + public void setBlobB64(String blobB64) { this.blobB64 = blobB64; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Response.java new file mode 100644 index 0000000..fc0f0e9 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_DeleteMessage_Response.java @@ -0,0 +1,19 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_DeleteMessage_Response extends Net_Response { + private String messageKey; + private String baseKey; + private int deliveredWsSessions; + private int deliveredWebPushSessions; + + public String getMessageKey() { return messageKey; } + public void setMessageKey(String messageKey) { this.messageKey = messageKey; } + public String getBaseKey() { return baseKey; } + public void setBaseKey(String baseKey) { this.baseKey = baseKey; } + public int getDeliveredWsSessions() { return deliveredWsSessions; } + public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; } + public int getDeliveredWebPushSessions() { return deliveredWebPushSessions; } + public void setDeliveredWebPushSessions(int deliveredWebPushSessions) { this.deliveredWebPushSessions = deliveredWebPushSessions; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Request.java new file mode 100644 index 0000000..56c65e1 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Request.java @@ -0,0 +1,87 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_SendSignal_Request extends Net_Request { + private String toLogin; + private String targetMode; + private String targetSessionId; + private String signalType; + private String signalRequestId; + private String data; + private Long timeMs; + private String sessionSignatureB64; + private String clientSignatureB64; + + public String getToLogin() { + return toLogin; + } + + public void setToLogin(String toLogin) { + this.toLogin = toLogin; + } + + public String getTargetMode() { + return targetMode; + } + + public void setTargetMode(String targetMode) { + this.targetMode = targetMode; + } + + public String getTargetSessionId() { + return targetSessionId; + } + + public void setTargetSessionId(String targetSessionId) { + this.targetSessionId = targetSessionId; + } + + public String getSignalType() { + return signalType; + } + + public void setSignalType(String signalType) { + this.signalType = signalType; + } + + public String getSignalRequestId() { + return signalRequestId; + } + + public void setSignalRequestId(String signalRequestId) { + this.signalRequestId = signalRequestId; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public Long getTimeMs() { + return timeMs; + } + + public void setTimeMs(Long timeMs) { + this.timeMs = timeMs; + } + + public String getSessionSignatureB64() { + return sessionSignatureB64; + } + + public void setSessionSignatureB64(String sessionSignatureB64) { + this.sessionSignatureB64 = sessionSignatureB64; + } + + public String getClientSignatureB64() { + return clientSignatureB64; + } + + public void setClientSignatureB64(String clientSignatureB64) { + this.clientSignatureB64 = clientSignatureB64; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java new file mode 100644 index 0000000..53b807d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_SendSignal_Response.java @@ -0,0 +1,27 @@ +package server.logic.ws_protocol.JSON.messages.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_SendSignal_Response extends Net_Response { + private int deliveredCount; + private List deliveredSessionIds = new ArrayList<>(); + + public int getDeliveredCount() { + return deliveredCount; + } + + public void setDeliveredCount(int deliveredCount) { + this.deliveredCount = deliveredCount; + } + + public List getDeliveredSessionIds() { + return deliveredSessionIds; + } + + public void setDeliveredSessionIds(List deliveredSessionIds) { + this.deliveredSessionIds = deliveredSessionIds; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java new file mode 100644 index 0000000..b2fd337 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java @@ -0,0 +1,348 @@ +package server.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +import shine.db.dao.BlocksDAO; +import shine.db.dao.SyncServersDAO; +import shine.db.entities.BlockEntry; +import shine.db.entities.SyncServerEntry; +import utils.blockchain.BlockchainNameUtil; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Фоновая one-shot репликация AddBlock на серверы из локальной таблицы sync_servers. + */ +public final class AddBlockSyncService { + + private static final Logger log = LoggerFactory.getLogger(AddBlockSyncService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .build(); + private static final ExecutorService EXECUTOR = new ThreadPoolExecutor( + 1, + Math.max(2, Runtime.getRuntime().availableProcessors()), + 60L, + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(10_000), + new ThreadFactory() { + private final AtomicLong n = new AtomicLong(1); + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "sync-addblock-" + n.getAndIncrement()); + t.setDaemon(true); + return t; + } + }, + new ThreadPoolExecutor.DiscardPolicy() + ); + + private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); + private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance(); + + public void replicateAsync(String blockchainName, int blockNumber) { + EXECUTOR.execute(() -> { + try { + replicate(blockchainName, blockNumber); + } catch (Exception e) { + log.error("AddBlock sync failed unexpectedly (blockchainName={}, blockNumber={})", + blockchainName, blockNumber, e); + } + }); + } + + private void replicate(String blockchainName, int blockNumber) throws Exception { + String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName)); + if (ownerLogin == null) { + log.warn("AddBlock sync skipped: cannot derive owner login from blockchainName={}", blockchainName); + return; + } + + List partners = syncServersDAO.listAll(); + if (partners.isEmpty()) { + return; + } + + BlockEntry currentBlock = blocksDAO.getByNumber(blockchainName, blockNumber); + if (currentBlock == null || currentBlock.getBlockBytes() == null) { + log.warn("AddBlock sync skipped: block not found in DB (blockchainName={}, blockNumber={})", + blockchainName, blockNumber); + return; + } + + for (SyncServerEntry partner : partners) { + if (partner == null) continue; + String partnerLogin = normalize(partner.getLogin()); + if (partnerLogin == null) continue; + if (partnerLogin.equals(ownerLogin)) { + continue; + } + try { + replicateToPartner(partner, blockchainName, blockNumber, currentBlock); + } catch (Exception e) { + log.warn("AddBlock sync aborted for partner login={} blockchainName={} blockNumber={} reason={}", + partnerLogin, blockchainName, blockNumber, e.toString()); + } + } + } + + private void replicateToPartner(SyncServerEntry partner, String blockchainName, int blockNumber, BlockEntry currentBlock) throws Exception { + String wsUrl = buildWsUrl(partner.getServerAddress()); + if (wsUrl == null) { + log.warn("AddBlock sync skipped: invalid server_address for partner login={} address={}", + partner.getLogin(), partner.getServerAddress()); + return; + } + + AddBlockPushResult firstTry = pushBlock(wsUrl, blockchainName, currentBlock); + if (firstTry.ok()) { + log.info("AddBlock sync ok: partner={} blockchainName={} blockNumber={}", + partner.getLogin(), blockchainName, blockNumber); + return; + } + + if (!firstTry.needsBackfill()) { + log.warn("AddBlock sync failed without backfill: partner={} blockchainName={} blockNumber={} code={}", + partner.getLogin(), blockchainName, blockNumber, firstTry.code()); + return; + } + + int remoteLast = firstTry.serverLastGlobalNumber(); + int fromBlockNumber = remoteLast + 1; + if (fromBlockNumber > blockNumber) { + log.warn("AddBlock sync inconsistent backfill window: partner={} blockchainName={} remoteLast={} target={}", + partner.getLogin(), blockchainName, remoteLast, blockNumber); + return; + } + + List missingBlocks = blocksDAO.listRangeByNumber(blockchainName, fromBlockNumber, blockNumber); + if (missingBlocks.isEmpty()) { + log.warn("AddBlock sync backfill failed: local range empty partner={} blockchainName={} from={} to={}", + partner.getLogin(), blockchainName, fromBlockNumber, blockNumber); + return; + } + + for (BlockEntry blockEntry : missingBlocks) { + AddBlockPushResult backfillResult = pushBlock(wsUrl, blockchainName, blockEntry); + if (!backfillResult.ok()) { + log.warn("AddBlock sync backfill failed: partner={} blockchainName={} blockNumber={} code={}", + partner.getLogin(), blockchainName, blockEntry.getBlockNumber(), backfillResult.code()); + return; + } + } + + log.info("AddBlock sync backfill ok: partner={} blockchainName={} from={} to={}", + partner.getLogin(), blockchainName, fromBlockNumber, blockNumber); + } + + private AddBlockPushResult pushBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception { + JsonNode response = sendAddBlock(wsUrl, blockchainName, blockEntry); + int status = response.path("status").asInt(500); + if (status >= 200 && status < 300) { + return AddBlockPushResult.success(); + } + + String code = textOrEmpty(response, "code"); + if (code.isBlank()) { + code = textOrEmpty(response, "error"); + } + JsonNode payload = response.path("payload"); + int serverLastGlobalNumber = payload.path("serverLastGlobalNumber").asInt(Integer.MIN_VALUE); + String serverLastGlobalHash = payload.path("serverLastGlobalHash").asText(""); + + return new AddBlockPushResult(false, status, code, serverLastGlobalNumber, serverLastGlobalHash); + } + + private JsonNode sendAddBlock(String wsUrl, String blockchainName, BlockEntry blockEntry) throws Exception { + CompletableFuture responseFuture = new CompletableFuture<>(); + CountDownLatch openLatch = new CountDownLatch(1); + SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); + + WebSocket webSocket = HTTP.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .buildAsync(URI.create(wsUrl), listener) + .get(8, TimeUnit.SECONDS); + + if (!openLatch.await(8, TimeUnit.SECONDS)) { + tryAbort(webSocket); + throw new TimeoutException("WS open timeout"); + } + + String requestId = "sync-" + UUID.randomUUID(); + String json = buildAddBlockJson(requestId, blockchainName, blockEntry); + webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); + + String responseJson = responseFuture.get(12, TimeUnit.SECONDS); + tryAbort(webSocket); + return MAPPER.readTree(responseJson); + } + + private String buildAddBlockJson(String requestId, String blockchainName, BlockEntry blockEntry) throws Exception { + String prevHashHex = blockEntry.getBlockNumber() <= 0 + ? "" + : toHex(extractPrevHash32(blockEntry.getBlockBytes())); + String blockBytesB64 = Base64Ws.encode(blockEntry.getBlockBytes()); + + String safeBlockchainName = MAPPER.writeValueAsString(blockchainName); + String safePrevHashHex = MAPPER.writeValueAsString(prevHashHex); + String safeBlockBytes = MAPPER.writeValueAsString(blockBytesB64); + String safeRequestId = MAPPER.writeValueAsString(requestId); + + return """ + { + "op":"AddBlock", + "requestId":%s, + "payload":{ + "blockchainName":%s, + "blockNumber":%d, + "prevBlockHash":%s, + "blockBytesB64":%s + } + } + """.formatted(safeRequestId, safeBlockchainName, blockEntry.getBlockNumber(), safePrevHashHex, safeBlockBytes); + } + + private static byte[] extractPrevHash32(byte[] blockBytes) { + if (blockBytes == null || blockBytes.length < 44) { + return new byte[32]; + } + byte[] out = new byte[32]; + System.arraycopy(blockBytes, 12, out, 0, 32); + return out; + } + + private static String textOrEmpty(JsonNode node, String field) { + return node == null ? "" : node.path(field).asText(""); + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim().toLowerCase(Locale.ROOT); + return s.isEmpty() ? null : s; + } + + private static String buildWsUrl(String serverAddressRaw) { + String host = normalizeHostLike(serverAddressRaw); + if (host == null) return null; + return "wss://" + host + "/ws"; + } + + private static String normalizeHostLike(String value) { + if (value == null) return null; + String raw = value.trim(); + if (raw.isEmpty()) return null; + try { + String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw; + URI uri = URI.create(withScheme); + String host = uri.getHost(); + if (host == null || host.isBlank()) return null; + return host.trim().toLowerCase(Locale.ROOT); + } catch (Exception e) { + String cleaned = raw + .replaceFirst("^[a-zA-Z]+://", "") + .replaceFirst("/.*$", "") + .trim() + .toLowerCase(Locale.ROOT); + return cleaned.isEmpty() ? null : cleaned; + } + } + + private static String toHex(byte[] bytes) { + if (bytes == null) return ""; + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >>> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + private static void tryAbort(WebSocket webSocket) { + try { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); + } catch (Exception ignored) { + } + try { + webSocket.abort(); + } catch (Exception ignored) { + } + } + + private record AddBlockPushResult( + boolean ok, + int status, + String code, + int serverLastGlobalNumber, + String serverLastGlobalHash + ) { + static AddBlockPushResult success() { + return new AddBlockPushResult(true, 200, "", Integer.MIN_VALUE, ""); + } + + boolean needsBackfill() { + return !ok && ("bad_prev_hash".equalsIgnoreCase(code) || "bad_block_number".equalsIgnoreCase(code)); + } + } + + private static final class SyncWsListener implements WebSocket.Listener { + private final CompletableFuture responseFuture; + private final CountDownLatch openLatch; + private final StringBuilder textBuffer = new StringBuilder(); + + private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { + this.responseFuture = responseFuture; + this.openLatch = openLatch; + } + + @Override + public void onOpen(WebSocket webSocket) { + openLatch.countDown(); + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + textBuffer.append(data); + if (last && !responseFuture.isDone()) { + responseFuture.complete(textBuffer.toString()); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason)); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally(error); + } + openLatch.countDown(); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java new file mode 100644 index 0000000..44fa49c --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java @@ -0,0 +1,92 @@ +package server.sync; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import utils.files.FileStoreUtil; + +/** + * In-memory guard для цепочек, которые сейчас находятся в полном resync. + * + * Задача guard-а: + * - не давать обычному AddBlock писать в цепочку, пока она пересобирается; + * - позволять внутреннему resync-потоку безопасно вызывать тот же AddBlock-путь + * через thread-local bypass. + */ +public final class BlockchainResyncGuard { + + private static final Set ACTIVE = ConcurrentHashMap.newKeySet(); + private static final ThreadLocal> BYPASS = ThreadLocal.withInitial(HashSet::new); + + private BlockchainResyncGuard() {} + + public static boolean tryBegin(String blockchainName) { + String key = normalize(blockchainName); + if (key == null) return false; + return ACTIVE.add(key); + } + + public static void end(String blockchainName) { + String key = normalize(blockchainName); + if (key == null) return; + ACTIVE.remove(key); + } + + public static boolean isBlockedForExternalAddBlock(String blockchainName) { + String key = normalize(blockchainName); + if (key == null) return false; + if (isBypassed(key)) { + return false; + } + if (ACTIVE.contains(key)) { + return true; + } + return FileStoreUtil.getInstance().existsBlockchainResyncMarker(key); + } + + public static T withBypass(String blockchainName, ThrowingSupplier supplier) throws Exception { + String key = normalize(blockchainName); + if (key == null) { + return supplier.get(); + } + + Set bypassSet = BYPASS.get(); + bypassSet.add(key); + try { + return supplier.get(); + } finally { + bypassSet.remove(key); + if (bypassSet.isEmpty()) { + BYPASS.remove(); + } + } + } + + public static void withBypass(String blockchainName, ThrowingRunnable runnable) throws Exception { + withBypass(blockchainName, () -> { + runnable.run(); + return null; + }); + } + + private static boolean isBypassed(String blockchainName) { + return BYPASS.get().contains(blockchainName); + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim(); + return s.isEmpty() ? null : s; + } + + @FunctionalInterface + public interface ThrowingSupplier { + T get() throws Exception; + } + + @FunctionalInterface + public interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java new file mode 100644 index 0000000..5058975 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/DmFederationService.java @@ -0,0 +1,100 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; +import utils.config.AppConfig; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class DmFederationService { + private static final Logger log = LoggerFactory.getLogger(DmFederationService.class); + private static final String CONFIG_KEY = "server.SHiNE.login"; + private static final RemoteDmSyncClient REMOTE = new RemoteDmSyncClient(); + + private DmFederationService() {} + + public static void fanOutPair(String fromLogin, String toLogin, String incomingBlobB64, String outgoingBlobB64) { + try { + Map senderRoutes = + routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin)); + Map recipientRoutes = + routesByLogin(SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin)); + + String ownServerLogin = ownServerLogin(); + for (SolanaUserPdaImportService.ParsedServerRoute route : senderRoutes.values()) { + if (isOwnServer(route, ownServerLogin)) continue; + REMOTE.sendMessagePair(route.serverAddress(), incomingBlobB64, outgoingBlobB64); + } + for (SolanaUserPdaImportService.ParsedServerRoute route : recipientRoutes.values()) { + if (isOwnServer(route, ownServerLogin)) continue; + if (senderRoutes.containsKey(route.login())) continue; + REMOTE.receiveIncomingMessage(route.serverAddress(), incomingBlobB64); + } + } catch (Exception e) { + log.warn("DM federation pair fan-out failed: from={} to={}", fromLogin, toLogin, e); + } + } + + public static void fanOutDeleteMessage(String fromLogin, String toLogin, int messageType, String blobB64) { + fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, true); + } + + public static void fanOutDeleteConversation(String fromLogin, String toLogin, int messageType, String blobB64) { + fanOutSingleDelete(fromLogin, toLogin, messageType, blobB64, false); + } + + private static void fanOutSingleDelete(String fromLogin, String toLogin, int messageType, String blobB64, boolean oneMessageDelete) { + try { + Map routes = routesByLogin( + SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(fromLogin), + SolanaUserPdaImportService.fetchAccessServerRoutesByLogin(toLogin) + ); + String ownServerLogin = ownServerLogin(); + for (SolanaUserPdaImportService.ParsedServerRoute route : routes.values()) { + if (isOwnServer(route, ownServerLogin)) continue; + if (oneMessageDelete) { + REMOTE.deleteMessage(route.serverAddress(), blobB64); + } else { + REMOTE.deleteConversation(route.serverAddress(), blobB64); + } + } + } catch (Exception e) { + log.warn("DM federation delete fan-out failed: from={} to={} type={}", + fromLogin, toLogin, messageType, e); + } + } + + @SafeVarargs + private static Map routesByLogin( + List... routeLists + ) { + Map out = new LinkedHashMap<>(); + for (List routeList : routeLists) { + for (SolanaUserPdaImportService.ParsedServerRoute route : routeList) { + if (route == null) continue; + String login = normalize(route.login()); + String address = route.serverAddress() == null ? "" : route.serverAddress().trim(); + if (login == null || address.isBlank()) continue; + out.putIfAbsent(login, route); + } + } + return out; + } + + private static boolean isOwnServer(SolanaUserPdaImportService.ParsedServerRoute route, String ownServerLogin) { + return ownServerLogin != null && ownServerLogin.equalsIgnoreCase(route.login()); + } + + private static String ownServerLogin() { + return normalize(AppConfig.getInstance().getParam(CONFIG_KEY)); + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim().toLowerCase(); + return s.isEmpty() ? null : s; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteBlockchainSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteBlockchainSyncClient.java new file mode 100644 index 0000000..1ac52c9 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteBlockchainSyncClient.java @@ -0,0 +1,272 @@ +package server.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Минимальный клиент для межсерверных JSON-op запросов по WSS. + */ +public final class RemoteBlockchainSyncClient { + + private static final Logger log = LoggerFactory.getLogger(RemoteBlockchainSyncClient.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .build(); + + public List listBlockchainHeads(String serverAddressRaw) throws Exception { + JsonNode response = send(serverAddressRaw, """ + { + "op":"ListBlockchainHeads", + "requestId":%s, + "payload":{} + } + """); + + int status = response.path("status").asInt(500); + if (status < 200 || status >= 300) { + throw new IllegalStateException("ListBlockchainHeads failed: status=" + status + " code=" + errorCode(response)); + } + + List result = new ArrayList<>(); + JsonNode items = response.path("payload").path("blockchains"); + if (!items.isArray()) { + return result; + } + for (JsonNode item : items) { + result.add(new RemoteBlockchainHead( + item.path("blockchainName").asText(""), + item.path("lastBlockNumber").asInt(-1), + item.path("lastBlockHash").asText(""), + item.path("fileSizeBytes").asLong(0L) + )); + } + return result; + } + + public RemoteSyncUserProfile getSyncUserProfile(String serverAddressRaw, String login) throws Exception { + String safeLogin = MAPPER.writeValueAsString(login); + JsonNode response = send(serverAddressRaw, """ + { + "op":"GetSyncUserProfile", + "requestId":%s, + "payload":{ + "login":%s + } + } + """.formatted("%s", safeLogin)); + + int status = response.path("status").asInt(500); + if (status == 404) { + return null; + } + if (status < 200 || status >= 300) { + throw new IllegalStateException("GetSyncUserProfile failed: status=" + status + " code=" + errorCode(response)); + } + + JsonNode payload = response.path("payload"); + if (!payload.path("exists").asBoolean(false)) { + return null; + } + + return new RemoteSyncUserProfile( + payload.path("login").asText(login), + payload.path("blockchainName").asText(""), + payload.path("solanaKey").asText(""), + payload.path("blockchainKey").asText(""), + payload.path("clientKey").asText(""), + payload.path("blockchainSizeLimitBytes").asLong(0L) + ); + } + + public RemoteBlockchainBlock getBlockchainBlock(String serverAddressRaw, String blockchainName, int blockNumber) throws Exception { + String safeBlockchainName = MAPPER.writeValueAsString(blockchainName); + JsonNode response = send(serverAddressRaw, """ + { + "op":"GetBlockchainBlock", + "requestId":%s, + "payload":{ + "blockchainName":%s, + "blockNumber":%d + } + } + """.formatted("%s", safeBlockchainName, blockNumber)); + + int status = response.path("status").asInt(500); + if (status == 404) { + return null; + } + if (status < 200 || status >= 300) { + throw new IllegalStateException("GetBlockchainBlock failed: status=" + status + " code=" + errorCode(response)); + } + + JsonNode payload = response.path("payload"); + return new RemoteBlockchainBlock( + payload.path("blockchainName").asText(blockchainName), + payload.path("blockNumber").asInt(blockNumber), + payload.path("blockHash").asText(""), + payload.path("blockBytesB64").asText("") + ); + } + + private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { + String requestId = MAPPER.writeValueAsString("sync-" + UUID.randomUUID()); + String json = jsonTemplate.formatted(requestId); + String wsUrl = buildWsUrl(serverAddressRaw); + if (wsUrl == null) { + throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); + } + + CompletableFuture responseFuture = new CompletableFuture<>(); + CountDownLatch openLatch = new CountDownLatch(1); + SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); + + WebSocket webSocket = HTTP.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .buildAsync(URI.create(wsUrl), listener) + .get(8, TimeUnit.SECONDS); + + if (!openLatch.await(8, TimeUnit.SECONDS)) { + tryAbort(webSocket); + throw new TimeoutException("WS open timeout"); + } + + webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); + String responseJson = responseFuture.get(12, TimeUnit.SECONDS); + tryAbort(webSocket); + return MAPPER.readTree(responseJson); + } + + private static String errorCode(JsonNode response) { + String code = response.path("code").asText(""); + if (!code.isBlank()) return code; + return response.path("error").asText(""); + } + + static String buildWsUrl(String serverAddressRaw) { + String host = normalizeHostLike(serverAddressRaw); + if (host == null) return null; + return "wss://" + host + "/ws"; + } + + private static String normalizeHostLike(String value) { + if (value == null) return null; + String raw = value.trim(); + if (raw.isEmpty()) return null; + try { + String withScheme = raw.matches("^[a-zA-Z]+://.*$") ? raw : "https://" + raw; + URI uri = URI.create(withScheme); + String host = uri.getHost(); + if (host == null || host.isBlank()) return null; + return host.trim().toLowerCase(Locale.ROOT); + } catch (Exception e) { + String cleaned = raw + .replaceFirst("^[a-zA-Z]+://", "") + .replaceFirst("/.*$", "") + .trim() + .toLowerCase(Locale.ROOT); + return cleaned.isEmpty() ? null : cleaned; + } + } + + private static void tryAbort(WebSocket webSocket) { + try { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); + } catch (Exception ignored) { + } + try { + webSocket.abort(); + } catch (Exception ignored) { + } + } + + public record RemoteBlockchainHead( + String blockchainName, + int lastBlockNumber, + String lastBlockHash, + long fileSizeBytes + ) {} + + public record RemoteBlockchainBlock( + String blockchainName, + int blockNumber, + String blockHash, + String blockBytesB64 + ) {} + + public record RemoteSyncUserProfile( + String login, + String blockchainName, + String solanaKey, + String blockchainKey, + String clientKey, + long blockchainSizeLimitBytes + ) {} + + private static final class SyncWsListener implements WebSocket.Listener { + private final CompletableFuture responseFuture; + private final CountDownLatch openLatch; + private final StringBuilder textBuffer = new StringBuilder(); + + private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { + this.responseFuture = responseFuture; + this.openLatch = openLatch; + } + + @Override + public void onOpen(WebSocket webSocket) { + openLatch.countDown(); + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + textBuffer.append(data); + if (last && !responseFuture.isDone()) { + responseFuture.complete(textBuffer.toString()); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason)); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + log.warn("Remote sync websocket error: {}", String.valueOf(error)); + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally(error); + } + openLatch.countDown(); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java new file mode 100644 index 0000000..c71d718 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/RemoteDmSyncClient.java @@ -0,0 +1,177 @@ +package server.sync; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class RemoteDmSyncClient { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .build(); + + public void sendMessagePair(String serverAddressRaw, String incomingBlobB64, String outgoingBlobB64) throws Exception { + String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); + String outgoingJson = MAPPER.writeValueAsString(outgoingBlobB64); + JsonNode response = send(serverAddressRaw, """ + { + "op":"ReceiveOutcomingMessage", + "requestId":%s, + "payload":{ + "incomingBlobB64":%s, + "outgoingBlobB64":%s + } + } + """.formatted("%s", incomingJson, outgoingJson)); + ensureOk("ReceiveOutcomingMessage", response); + } + + public void receiveIncomingMessage(String serverAddressRaw, String incomingBlobB64) throws Exception { + String incomingJson = MAPPER.writeValueAsString(incomingBlobB64); + JsonNode response = send(serverAddressRaw, """ + { + "op":"ReceiveIncomingMessage", + "requestId":%s, + "payload":{ + "incomingBlobB64":%s + } + } + """.formatted("%s", incomingJson)); + ensureOk("ReceiveIncomingMessage", response); + } + + public void deleteMessage(String serverAddressRaw, String blobB64) throws Exception { + String blobJson = MAPPER.writeValueAsString(blobB64); + JsonNode response = send(serverAddressRaw, """ + { + "op":"DeleteMessage", + "requestId":%s, + "payload":{ + "blobB64":%s + } + } + """.formatted("%s", blobJson)); + ensureOk("DeleteMessage", response); + } + + public void deleteConversation(String serverAddressRaw, String blobB64) throws Exception { + String blobJson = MAPPER.writeValueAsString(blobB64); + JsonNode response = send(serverAddressRaw, """ + { + "op":"DeleteConversation", + "requestId":%s, + "payload":{ + "blobB64":%s + } + } + """.formatted("%s", blobJson)); + ensureOk("DeleteConversation", response); + } + + private JsonNode send(String serverAddressRaw, String jsonTemplate) throws Exception { + String requestId = MAPPER.writeValueAsString("dm-sync-" + UUID.randomUUID()); + String json = jsonTemplate.formatted(requestId); + String wsUrl = RemoteBlockchainSyncClient.buildWsUrl(serverAddressRaw); + if (wsUrl == null) { + throw new IllegalArgumentException("Invalid server address: " + serverAddressRaw); + } + + CompletableFuture responseFuture = new CompletableFuture<>(); + CountDownLatch openLatch = new CountDownLatch(1); + SyncWsListener listener = new SyncWsListener(responseFuture, openLatch); + + WebSocket webSocket = HTTP.newWebSocketBuilder() + .connectTimeout(Duration.ofSeconds(6)) + .buildAsync(URI.create(wsUrl), listener) + .get(8, TimeUnit.SECONDS); + + if (!openLatch.await(8, TimeUnit.SECONDS)) { + tryAbort(webSocket); + throw new TimeoutException("WS open timeout"); + } + + webSocket.sendText(json, true).get(8, TimeUnit.SECONDS); + String responseJson = responseFuture.get(12, TimeUnit.SECONDS); + tryAbort(webSocket); + return MAPPER.readTree(responseJson); + } + + private void ensureOk(String op, JsonNode response) { + int status = response.path("status").asInt(500); + if (status >= 200 && status < 300) return; + String code = response.path("code").asText(""); + if (code.isBlank()) code = response.path("error").asText(""); + throw new IllegalStateException(op + " failed: status=" + status + " code=" + code); + } + + private static void tryAbort(WebSocket webSocket) { + try { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "ok"); + } catch (Exception ignored) { + } + try { + webSocket.abort(); + } catch (Exception ignored) { + } + } + + private static final class SyncWsListener implements WebSocket.Listener { + private final CompletableFuture responseFuture; + private final CountDownLatch openLatch; + private final StringBuilder textBuffer = new StringBuilder(); + + private SyncWsListener(CompletableFuture responseFuture, CountDownLatch openLatch) { + this.responseFuture = responseFuture; + this.openLatch = openLatch; + } + + @Override + public void onOpen(WebSocket webSocket) { + openLatch.countDown(); + webSocket.request(1); + } + + @Override + public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + textBuffer.append(data); + if (last && !responseFuture.isDone()) { + responseFuture.complete(textBuffer.toString()); + } + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onBinary(WebSocket webSocket, ByteBuffer data, boolean last) { + webSocket.request(1); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally(new IllegalStateException("WS closed before response: " + statusCode + " " + reason)); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + if (!responseFuture.isDone()) { + responseFuture.completeExceptionally(error); + } + openLatch.countDown(); + } + } +} diff --git a/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java new file mode 100644 index 0000000..4d6755f --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java @@ -0,0 +1,149 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import shine.db.entities.SyncServerEntry; +import utils.files.FileStoreUtil; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Startup-recovery для цепочек, которые были помечены как resync-pending. + * + * Правило простое: + * - marker-file означает, что chain должен быть пересобран заново; + * - мы не пытаемся продолжать с середины; + * - если recovery не завершён, обычная работа сервера не стартует. + */ +public final class BlockchainResyncRecoveryOnStartup { + + private static final Logger log = LoggerFactory.getLogger(BlockchainResyncRecoveryOnStartup.class); + private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient(); + + private BlockchainResyncRecoveryOnStartup() {} + + public static void runRecoveryOrThrow() { + Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); + ensureDirExists(dataDir); + + List markers = listMarkerFiles(dataDir); + if (markers.isEmpty()) { + log.info("🟢 BlockchainResyncRecovery: resync marker-файлы не найдены."); + return; + } + + log.warn("🟡 BlockchainResyncRecovery: найдено marker-файлов: {}", markers.size()); + for (Path marker : markers) { + recoverSingleMarkerOrThrow(marker); + } + log.info("✅ BlockchainResyncRecovery: все marker-файлы обработаны."); + } + + private static void recoverSingleMarkerOrThrow(Path markerPath) { + String fileName = markerPath.getFileName().toString(); + String blockchainName = extractBlockchainName(fileName); + if (blockchainName == null || blockchainName.isBlank()) { + throw new IllegalStateException("Bad resync marker name: " + fileName); + } + + Map meta = parseMarker(markerPath); + String partnerLogin = normalize(meta.get("partnerLogin")); + String partnerAddress = normalize(meta.get("partnerAddress")); + if (partnerAddress == null) { + throw new IllegalStateException("Resync marker has no partnerAddress for blockchainName=" + blockchainName); + } + + log.warn("🔁 BlockchainResyncRecovery: processing marker blockchainName={} partnerLogin={} partnerAddress={}", + blockchainName, partnerLogin, partnerAddress); + + try { + List heads = REMOTE.listBlockchainHeads(partnerAddress); + RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream() + .filter(h -> h != null && blockchainName.equals(h.blockchainName())) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "Partner does not expose blockchainName=" + blockchainName + " address=" + partnerAddress)); + + SyncServerEntry partner = new SyncServerEntry( + partnerLogin == null ? "" : partnerLogin, + partnerAddress, + System.currentTimeMillis() + ); + + PeriodicBlockchainSyncService.resyncFromScratch(partner, remoteHead); + } catch (Exception e) { + throw new IllegalStateException( + "Не удалось восстановить resync-помеченную цепочку blockchainName=" + blockchainName, + e + ); + } + } + + private static List listMarkerFiles(Path dataDir) { + try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) { + return stream(ds).toList(); + } catch (IOException e) { + throw new IllegalStateException("Cannot list resync markers in: " + dataDir, e); + } + } + + private static java.util.stream.Stream stream(DirectoryStream ds) { + return java.util.stream.StreamSupport.stream(ds.spliterator(), false) + .filter(Files::isRegularFile); + } + + private static Map parseMarker(Path path) { + try { + List lines = Files.readAllLines(path, StandardCharsets.UTF_8); + Map result = new HashMap<>(); + for (String line : lines) { + if (line == null) continue; + String s = line.trim(); + if (s.isEmpty() || s.startsWith("#")) continue; + int idx = s.indexOf('='); + if (idx <= 0) continue; + String key = s.substring(0, idx).trim(); + String value = s.substring(idx + 1).trim(); + result.put(key, value); + } + return result; + } catch (IOException e) { + throw new IllegalStateException("Cannot read resync marker: " + path, e); + } + } + + private static void ensureDirExists(Path dir) { + try { + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + } catch (IOException e) { + throw new IllegalStateException("Cannot create data dir: " + dir, e); + } + } + + private static String extractBlockchainName(String fileName) { + if (fileName == null) return null; + String s = fileName.trim(); + if (!s.endsWith(FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) { + return null; + } + return s.substring(0, s.length() - FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION.length()); + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim(); + return s.isEmpty() ? null : s.toLowerCase(Locale.ROOT); + } +} diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java new file mode 100644 index 0000000..ddf7f7e --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java @@ -0,0 +1,468 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.entyties.Net_Exception_Response; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks; +import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request; +import shine.db.dao.BlockchainResyncCleanupDAO; +import shine.db.dao.BlockchainStateDAO; +import shine.db.dao.SyncServersDAO; +import shine.db.dao.UserCreateDAO; +import shine.db.dao.SolanaUsersDAO; +import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.SyncServerEntry; +import server.sync.BlockchainResyncGuard; +import utils.files.FileStoreUtil; +import utils.blockchain.BlockchainNameUtil; +import utils.config.AppConfig; + +import java.util.concurrent.locks.ReentrantLock; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Плановый межсерверный sync блокчейнов. + * Сейчас реализует: + * - догоняющую синхронизацию отсутствующего хвоста; + * - базовый full-resync при divergence, если удалённая цепочка сильнее. + */ +public final class PeriodicBlockchainSyncService { + + private static final Logger log = LoggerFactory.getLogger(PeriodicBlockchainSyncService.class); + private static final long PERIOD_HOURS = 12L; + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "periodic-blockchain-sync"); + t.setDaemon(true); + return t; + } + }); + + private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient(); + private static final Net_AddBlock_Handler ADD_BLOCK_HANDLER = new Net_AddBlock_Handler(); + private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance(); + private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance(); + private static final UserCreateDAO USER_CREATE_DAO = UserCreateDAO.getInstance(); + private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance(); + private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance(); + private static final SolanaUsersDAO SOLANA_USERS_DAO = SolanaUsersDAO.getInstance(); + private static final String CONFIG_IMPORT_PROFILE_FROM_PARTNER = "sync.importUserProfileFromPartner.enabled"; + + private PeriodicBlockchainSyncService() {} + + public static void startOrLog() { + if (!STARTED.compareAndSet(false, true)) { + return; + } + EXECUTOR.scheduleWithFixedDelay( + PeriodicBlockchainSyncService::runCycleSafe, + 0L, + PERIOD_HOURS, + TimeUnit.HOURS + ); + log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS); + } + + private static void runCycleSafe() { + try { + runCycle(); + } catch (Exception e) { + log.error("Periodic blockchain sync failed unexpectedly", e); + } + } + + private static void runCycle() throws Exception { + SyncServersBootstrapService.refreshFromSolanaOrLog(); + List partners = SYNC_SERVERS_DAO.listAll(); + if (partners.isEmpty()) { + log.info("Periodic blockchain sync skipped: sync_servers is empty"); + return; + } + + for (SyncServerEntry partner : partners) { + if (partner == null) continue; + try { + syncPartner(partner); + } catch (Exception e) { + log.warn("Periodic blockchain sync partner failed: login={} reason={}", + partner.getLogin(), String.valueOf(e)); + } + } + } + + private static void syncPartner(SyncServerEntry partner) throws Exception { + String partnerLogin = normalize(partner.getLogin()); + if (partnerLogin == null) return; + + List remoteHeads = + REMOTE.listBlockchainHeads(partner.getServerAddress()); + + for (RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead : remoteHeads) { + if (remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) { + continue; + } + + BlockchainStateEntry localState = STATE_DAO.getByBlockchainName(remoteHead.blockchainName()); + if (localState == null) { + syncMissingTail(partner, remoteHead, -1, ""); + continue; + } + + int localLast = localState.getLastBlockNumber(); + String localHash = toHex32(localState.getLastBlockHash()); + + if (localLast < remoteHead.lastBlockNumber()) { + syncMissingTail(partner, remoteHead, localLast, localHash); + continue; + } + + if (localLast == remoteHead.lastBlockNumber()) { + if (localHash.equalsIgnoreCase(remoteHead.lastBlockHash())) { + continue; + } + if (isRemoteStronger(localState, remoteHead)) { + log.warn("Periodic blockchain sync: divergence detected, remote chain is stronger, starting full resync. partner={} blockchainName={} localLast={} localHash={} remoteHash={} localSize={} remoteSize={}", + partnerLogin, + remoteHead.blockchainName(), + localLast, + localHash, + remoteHead.lastBlockHash(), + localState.getFileSizeBytes(), + remoteHead.fileSizeBytes()); + resyncFromScratch(partner, remoteHead); + } else { + log.info("Periodic blockchain sync skipped: local chain is stronger or equal. partner={} blockchainName={} localLast={} remoteLast={} localSize={} remoteSize={}", + partnerLogin, + remoteHead.blockchainName(), + localLast, + remoteHead.lastBlockNumber(), + localState.getFileSizeBytes(), + remoteHead.fileSizeBytes()); + } + continue; + } + + log.info("Periodic blockchain sync skipped: local chain is not weaker. partner={} blockchainName={} localLast={} remoteLast={}", + partnerLogin, remoteHead.blockchainName(), localLast, remoteHead.lastBlockNumber()); + } + } + + private static void syncMissingTail( + SyncServerEntry partner, + RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead, + int localLast, + String localHash + ) throws Exception { + String partnerLogin = normalize(partner.getLogin()); + if (!ensureLocalChainExists(partner, remoteHead.blockchainName())) { + log.warn("Periodic blockchain sync: cannot prepare local chain. partner={} blockchainName={}", + partnerLogin, remoteHead.blockchainName()); + return; + } + + int fromBlockNumber = Math.max(localLast + 1, 0); + for (int blockNumber = fromBlockNumber; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) { + RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock = + REMOTE.getBlockchainBlock(partner.getServerAddress(), remoteHead.blockchainName(), blockNumber); + if (remoteBlock == null) { + log.warn("Periodic blockchain sync: remote block not found. partner={} blockchainName={} blockNumber={}", + partnerLogin, remoteHead.blockchainName(), blockNumber); + return; + } + + LocalAddBlockApplyResult result = applyBlockLocally(remoteBlock, blockNumber == 0 ? "" : localHash); + if (!result.ok()) { + if ("bad_prev_hash".equalsIgnoreCase(result.code()) || "bad_block_number".equalsIgnoreCase(result.code())) { + log.warn("Periodic blockchain sync: divergence detected during replay, starting full resync. partner={} blockchainName={} blockNumber={} code={}", + partnerLogin, remoteHead.blockchainName(), blockNumber, result.code()); + resyncFromScratch(partner, remoteHead); + } else { + log.warn("Periodic blockchain sync: local AddBlock rejected remote block. partner={} blockchainName={} blockNumber={} code={} message={}", + partnerLogin, remoteHead.blockchainName(), blockNumber, result.code(), result.message()); + } + return; + } + + localHash = result.serverLastHash(); + } + + log.info("Periodic blockchain sync ok: partner={} blockchainName={} from={} to={}", + partnerLogin, remoteHead.blockchainName(), fromBlockNumber, remoteHead.lastBlockNumber()); + } + + static void resyncFromScratch( + SyncServerEntry partner, + RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead + ) throws Exception { + if (partner == null || remoteHead == null || remoteHead.blockchainName() == null || remoteHead.blockchainName().isBlank()) { + return; + } + + String blockchainName = remoteHead.blockchainName(); + String partnerLogin = normalize(partner.getLogin()); + + if (!BlockchainResyncGuard.tryBegin(blockchainName)) { + log.warn("Blockchain resync skipped: already in progress for blockchainName={}", blockchainName); + return; + } + + String markerContent = """ + blockchainName=%s + partnerLogin=%s + partnerAddress=%s + remoteLastBlockNumber=%d + remoteLastBlockHash=%s + remoteFileSizeBytes=%d + startedAtMs=%d + """.formatted( + blockchainName, + partnerLogin == null ? "" : partnerLogin, + partner.getServerAddress() == null ? "" : partner.getServerAddress(), + remoteHead.lastBlockNumber(), + remoteHead.lastBlockHash(), + remoteHead.fileSizeBytes(), + System.currentTimeMillis() + ); + + FILE_STORE.writeBlockchainResyncMarker(blockchainName, markerContent); + + ReentrantLock chainLock = BlockchainLocks.lockFor(blockchainName); + chainLock.lock(); + boolean success = false; + try { + BlockchainResyncCleanupDAO.CleanupResult cleanup = + RESYNC_CLEANUP_DAO.cleanupBlockchainForFullResync(blockchainName); + log.info("Blockchain resync cleanup finished: blockchainName={} login={} likesAdjusted={} repliesAdjusted={} deletedBlocks={} deletedState={}", + blockchainName, + cleanup.login(), + cleanup.likesAdjustedRows(), + cleanup.repliesAdjustedRows(), + cleanup.deletedBlocksRows(), + cleanup.deletedBlockchainStateRows()); + + FILE_STORE.deleteBlockchainFileIfExists(blockchainName); + FILE_STORE.deleteBlockchainTmpFileIfExists(blockchainName); + + if (!ensureLocalChainExists(partner, blockchainName)) { + throw new IllegalStateException("failed to recreate local chain state for " + blockchainName); + } + + boolean replayOk = replayRemoteChainFromStart(partner, remoteHead); + if (!replayOk) { + throw new IllegalStateException("failed to replay remote chain for " + blockchainName); + } + + FILE_STORE.deleteBlockchainResyncMarkerIfExists(blockchainName); + success = true; + log.info("Blockchain resync completed: partner={} blockchainName={} blocks=0..{}", + partnerLogin, blockchainName, remoteHead.lastBlockNumber()); + } finally { + chainLock.unlock(); + BlockchainResyncGuard.end(blockchainName); + } + + if (!success) { + throw new IllegalStateException("Blockchain resync did not complete for " + blockchainName); + } + } + + private static boolean replayRemoteChainFromStart( + SyncServerEntry partner, + RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead + ) throws Exception { + String blockchainName = remoteHead.blockchainName(); + String partnerLogin = normalize(partner.getLogin()); + + return BlockchainResyncGuard.withBypass(blockchainName, () -> { + String localPrevHash = ""; + for (int blockNumber = 0; blockNumber <= remoteHead.lastBlockNumber(); blockNumber++) { + RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock = + REMOTE.getBlockchainBlock(partner.getServerAddress(), blockchainName, blockNumber); + if (remoteBlock == null) { + log.warn("Blockchain resync: remote block not found. partner={} blockchainName={} blockNumber={}", + partnerLogin, blockchainName, blockNumber); + return false; + } + + LocalAddBlockApplyResult result = applyBlockLocally(remoteBlock, localPrevHash); + if (!result.ok()) { + log.warn("Blockchain resync: AddBlock rejected replay block. partner={} blockchainName={} blockNumber={} code={} message={}", + partnerLogin, blockchainName, blockNumber, result.code(), result.message()); + return false; + } + + localPrevHash = result.serverLastHash(); + } + return true; + }); + } + + private static boolean isRemoteStronger(BlockchainStateEntry localState, + RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead) { + if (localState == null || remoteHead == null) return false; + + int localLast = localState.getLastBlockNumber(); + int remoteLast = remoteHead.lastBlockNumber(); + if (remoteLast != localLast) { + return remoteLast > localLast; + } + + long localSize = localState.getFileSizeBytes(); + long remoteSize = remoteHead.fileSizeBytes(); + if (remoteSize != localSize) { + return remoteSize > localSize; + } + + String localHash = toHex32(localState.getLastBlockHash()).toLowerCase(Locale.ROOT); + String remoteHash = normalizeHex64(remoteHead.lastBlockHash()); + return remoteHash.compareTo(localHash) > 0; + } + + private static LocalAddBlockApplyResult applyBlockLocally( + RemoteBlockchainSyncClient.RemoteBlockchainBlock remoteBlock, + String prevHash + ) { + Net_AddBlock_Request req = new Net_AddBlock_Request(); + req.setOp("AddBlock"); + req.setRequestId("periodic-sync-local"); + req.setBlockchainName(remoteBlock.blockchainName()); + req.setBlockNumber(remoteBlock.blockNumber()); + req.setPrevBlockHash(prevHash == null ? "" : prevHash); + req.setBlockBytesB64(remoteBlock.blockBytesB64()); + + Net_Response response = ADD_BLOCK_HANDLER.handle(req, null); + if (response.getStatus() >= 200 && response.getStatus() < 300) { + server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response ok = + (server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response) response; + return new LocalAddBlockApplyResult(true, "", "", ok.getServerLastGlobalHash()); + } + + Net_Exception_Response error = (Net_Exception_Response) response; + return new LocalAddBlockApplyResult(false, error.getCode(), error.getMessage(), ""); + } + + private static boolean ensureLocalChainExists(SyncServerEntry partner, String blockchainName) { + try { + if (STATE_DAO.getByBlockchainName(blockchainName) != null) { + return true; + } + String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName); + if (login == null || login.isBlank()) { + return false; + } + if (AppConfig.getInstance().getBoolean(CONFIG_IMPORT_PROFILE_FROM_PARTNER, false)) { + return importUserProfileFromPartner(partner, login); + } + SolanaUserPdaImportService.findOrImportByLogin(login); + return STATE_DAO.getByBlockchainName(blockchainName) != null; + } catch (Exception e) { + log.warn("Periodic blockchain sync: failed to ensure local chain exists for blockchainName={} reason={}", + blockchainName, String.valueOf(e)); + return false; + } + } + + private static boolean importUserProfileFromPartner(SyncServerEntry partner, String login) throws Exception { + if (partner == null || partner.getServerAddress() == null || partner.getServerAddress().isBlank()) { + return false; + } + + RemoteBlockchainSyncClient.RemoteSyncUserProfile profile = + REMOTE.getSyncUserProfile(partner.getServerAddress(), login); + if (profile == null) { + log.warn("Periodic blockchain sync: partner has no sync profile for login={} partner={}", + login, normalize(partner.getLogin())); + return false; + } + + long now = System.currentTimeMillis(); + long sizeLimit = profile.blockchainSizeLimitBytes() > 0 ? profile.blockchainSizeLimitBytes() : 100_000L; + BlockchainStateEntry state = buildStateFromProfile(profile, sizeLimit, now); + + if (SOLANA_USERS_DAO.existsByLogin(profile.login())) { + STATE_DAO.insertIfMissing(state); + return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; + } + + boolean inserted = USER_CREATE_DAO.insertUserWithBlockchain( + profile.login(), + profile.blockchainName(), + profile.solanaKey(), + profile.blockchainKey(), + profile.clientKey(), + sizeLimit, + now + ); + + if (inserted) { + return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; + } + + // Если пользователь уже успел существовать локально, но chain_state отсутствует, + // добиваем только state и не пытаемся пересоздать identity. + if (SOLANA_USERS_DAO.existsByLogin(profile.login())) { + STATE_DAO.insertIfMissing(state); + return STATE_DAO.getByBlockchainName(profile.blockchainName()) != null; + } + + return false; + } + + private static BlockchainStateEntry buildStateFromProfile(RemoteBlockchainSyncClient.RemoteSyncUserProfile profile, + long sizeLimit, + long nowMs) { + BlockchainStateEntry state = new BlockchainStateEntry(); + state.setBlockchainName(profile.blockchainName()); + state.setLogin(profile.login()); + state.setBlockchainKey(profile.blockchainKey()); + state.setSizeLimit(sizeLimit); + state.setFileSizeBytes(0L); + state.setLastBlockNumber(-1); + state.setLastBlockHash(null); + state.setUpdatedAtMs(nowMs); + return state; + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim().toLowerCase(Locale.ROOT); + return s.isEmpty() ? null : s; + } + + private static String toHex32(byte[] bytes32) { + byte[] bytes = bytes32; + if (bytes == null || bytes.length != 32) { + bytes = new byte[32]; + } + StringBuilder sb = new StringBuilder(64); + for (byte b : bytes) { + sb.append(Character.forDigit((b >>> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + private static String normalizeHex64(String value) { + if (value == null) return ""; + String s = value.trim().toLowerCase(Locale.ROOT); + return s.length() == 64 ? s : ""; + } + + private record LocalAddBlockApplyResult( + boolean ok, + String code, + String message, + String serverLastHash + ) {} +} diff --git a/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java b/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java new file mode 100644 index 0000000..a7fe1e4 --- /dev/null +++ b/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java @@ -0,0 +1,85 @@ +package server.sync; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.auth.SolanaUserPdaImportService; +import shine.db.dao.SyncServersDAO; +import shine.db.entities.SyncServerEntry; +import utils.config.AppConfig; + +import java.util.ArrayList; +import java.util.List; + +/** + * При старте сервера читает server PDA текущего сервера, затем PDA партнёров из + * sync_servers и сохраняет их логины/адреса в локальную таблицу sync_servers. + */ +public final class SyncServersBootstrapService { + + private static final Logger log = LoggerFactory.getLogger(SyncServersBootstrapService.class); + private static final String CONFIG_KEY = "server.SHiNE.login"; + + private SyncServersBootstrapService() {} + + public static void refreshFromSolanaOrLog() { + String serverLogin = normalize(AppConfig.getInstance().getParam(CONFIG_KEY)); + if (serverLogin == null) { + log.warn("Sync bootstrap skipped: параметр {} не задан", CONFIG_KEY); + return; + } + + try { + SolanaUserPdaImportService.ParsedServerProfile own = + SolanaUserPdaImportService.fetchServerProfileByLogin(serverLogin); + if (own == null) { + log.warn("Sync bootstrap skipped: server PDA не найдена для login={}", serverLogin); + return; + } + if (!own.isServer()) { + log.warn("Sync bootstrap skipped: PDA login={} не помечена как server", serverLogin); + return; + } + + List entries = new ArrayList<>(); + long now = System.currentTimeMillis(); + for (String partnerLogin : own.syncServers()) { + String normalizedPartnerLogin = normalize(partnerLogin); + if (normalizedPartnerLogin == null) continue; + + SolanaUserPdaImportService.ParsedServerProfile partner = + SolanaUserPdaImportService.fetchServerProfileByLogin(normalizedPartnerLogin); + if (partner == null) { + log.warn("Sync bootstrap: partner PDA не найдена для login={}", normalizedPartnerLogin); + continue; + } + if (!partner.isServer()) { + log.warn("Sync bootstrap: partner login={} не является server PDA", normalizedPartnerLogin); + continue; + } + + String serverAddress = safe(partner.serverAddress()); + if (serverAddress.isBlank()) { + log.warn("Sync bootstrap: у partner login={} пустой server_address", normalizedPartnerLogin); + continue; + } + + entries.add(new SyncServerEntry(normalizedPartnerLogin, serverAddress, now)); + } + + SyncServersDAO.getInstance().replaceAll(entries); + log.info("Sync bootstrap: сохранено {} серверов синхронизации для login={}", entries.size(), serverLogin); + } catch (Exception e) { + log.error("Sync bootstrap failed while loading server PDA and sync_servers from Solana", e); + } + } + + private static String normalize(String value) { + if (value == null) return null; + String s = value.trim().toLowerCase(); + return s.isEmpty() ? null : s; + } + + private static String safe(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java index 5a2bd92..8e063df 100644 --- a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java +++ b/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java @@ -2,199 +2,299 @@ package server.ws; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import shine.db.dao.BlocksDAO; import shine.db.dao.BlockchainStateDAO; +import shine.db.entities.BlockEntry; import shine.db.entities.BlockchainStateEntry; import utils.files.FileStoreUtil; import shine.log.BlockchainAdminNotifier; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.sql.SQLException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.HexFormat; /** * =============================================================== * BlockchainTmpRecoveryOnStartup — восстановление консистентности - * blockchain файлов при старте сервера. + * файлов обычного AddBlock при старте сервера. * - * Сценарий проблемы: - * - при добавлении блока сначала пишется .tmp_bch - * - потом коммитится БД (state.fileSizeBytes) - * - потом tmp переименовывается поверх .bch (атомарно, если возможно) + * Новая модель обычной записи: + * 1) собирается .tmp_bch как полный кандидат на замену main; + * 2) пишется sidecar .write_check (blockNumber/blockHash); + * 3) создаётся пустой marker .write_pending; + * 4) выполняется SQL-транзакция; + * 5) после commit tmp атомарно ставится на место main; + * 6) marker и sidecar удаляются. * - * Если сервер упал в середине, может остаться tmp: - * - tmp есть, а основной .bch остался старым - * - tmp есть, а основной .bch уже удалили/заменить не успели - * - tmp есть, а БД успела/не успела обновиться + * На старте: + * - если найден marker, recovery добивает запись или чистит мусор; + * - если marker нет, а tmp/check остались, это мусор и он удаляется; + * - legacy tmp-файлы без marker тоже считаются мусором. * - * Этот класс при старте: - * - ищет все *.tmp_bch в data/ - * - сравнивает размеры: - * - tmp - * - main (если есть) - * - state.fileSizeBytes (если есть) - * - * Правила: - * - * A) state есть: - * - если stateSize == mainSize => tmp удаляем - * - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile) - * - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору - * - * B) state НЕТ: - * - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции) - * - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера - * - * Логирование: - * - обо всех восстановленных/удалённых tmp пишем в лог - * - если tmp-файлов нет — тоже пишем в лог + * Принцип: + * - marker означает, что операция вошла в опасную фазу и должна быть доведена до конца или откатана; + * - sidecar нужен только как маленькое описание текущей операции (blockNumber/blockHash); + * - если marker отсутствует, временные файлы не считаются валидными. * =============================================================== */ public final class BlockchainTmpRecoveryOnStartup { private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class); + private static final HexFormat HEX = HexFormat.of(); private BlockchainTmpRecoveryOnStartup() {} - /** - * Запуск восстановления. - * Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение. - */ public static void runRecoveryOrThrow() { FileStoreUtil fs = FileStoreUtil.getInstance(); BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); + BlocksDAO blocksDAO = BlocksDAO.getInstance(); Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); ensureDirExists(dataDir); - List tmpFiles = listTmpFiles(dataDir); - - if (tmpFiles.isEmpty()) { - log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется."); - return; + List markers = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION); + if (!markers.isEmpty()) { + log.warn("🟡 BlockchainTmpRecovery: найдено marker-файлов обычного AddBlock: {}", markers.size()); + } else { + log.info("🟢 BlockchainTmpRecovery: marker-файлов обычного AddBlock не найдено."); } - log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size()); + for (Path marker : markers) { + recoverSingleWriteMarkerOrThrow(marker, fs, stateDAO, blocksDAO); + } - for (Path tmpPath : tmpFiles) { - String fileName = tmpPath.getFileName().toString(); - String blockchainName = extractBlockchainNameFromTmp(fileName); + cleanupOrphanTempArtifacts(dataDir, fs); + log.info("✅ BlockchainTmpRecovery: обработка временных файлов AddBlock завершена."); + } - if (blockchainName == null || blockchainName.isBlank()) { - // странное имя — не трогаем автоматически, но это уже повод дернуть админа - BlockchainAdminNotifier.critical( - "НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).", - null - ); - throw new IllegalStateException("Bad tmp file name: " + fileName); - } + private static void recoverSingleWriteMarkerOrThrow(Path markerPath, + FileStoreUtil fs, + BlockchainStateDAO stateDAO, + BlocksDAO blocksDAO) { + String markerFileName = markerPath.getFileName().toString(); + String blockchainName = extractBlockchainName(markerFileName, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION); + if (blockchainName == null || blockchainName.isBlank()) { + BlockchainAdminNotifier.critical( + "НАЙДЕН write_pending marker С НЕОЖИДАННЫМ ИМЕНЕМ: " + markerFileName, + null + ); + throw new IllegalStateException("Bad write marker name: " + markerFileName); + } - Path mainPath = dataDir.resolve(fs.buildBlockchainFileName(blockchainName)); + Path tmpPath = fs.resolveBlockchainTmpPath(blockchainName); + Path checkPath = fs.resolveBlockchainWriteCheckPath(blockchainName); + Path mainPath = fs.resolveBlockchainPath(blockchainName); - long tmpSize = safeSize(tmpPath); + Map meta = parseKeyValueFile(checkPath); + Integer expectedBlockNumber = parseInt(meta.get("blockNumber")); + String expectedBlockHashHex = normalizeHex(meta.get("blockHash")); + + try { + BlockchainStateEntry st = stateDAO.getByBlockchainName(blockchainName); boolean mainExists = Files.exists(mainPath); + boolean tmpExists = Files.exists(tmpPath); long mainSize = mainExists ? safeSize(mainPath) : -1L; + long tmpSize = tmpExists ? safeSize(tmpPath) : -1L; - BlockchainStateEntry st = null; - try { - st = stateDAO.getByBlockchainName(blockchainName); - } catch (SQLException e) { - BlockchainAdminNotifier.critical( - "ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ TMP: blockchainName=" + blockchainName + " (сервер остановлен).", - e - ); - throw new IllegalStateException("DB error during tmp recovery for " + blockchainName, e); - } - - // ============================================================ - // CASE B) state НЕТ - // ============================================================ if (st == null) { - - if (!mainExists) { - // НЕТ state, НЕТ main, есть tmp => удаляем tmp - log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}", - blockchainName, tmpSize); - safeDelete(tmpPath); - continue; - } - - // НЕТ state, но main есть и tmp есть => это уже подозрительно - BlockchainAdminNotifier.critical( - "НЕСОГЛАСОВАННОСТЬ: ЕСТЬ main И tmp, НО НЕТ state В БД. " + - "blockchainName=" + blockchainName + - ", mainSize=" + mainSize + - ", tmpSize=" + tmpSize + - ". СЕРВЕР ОСТАНОВЛЕН. " + - "ПОДОЗРЕНИЕ: файлы могли быть изменены вне сервера.", - null - ); - throw new IllegalStateException("State missing but both main and tmp exist for " + blockchainName); + log.warn("🟠 BlockchainTmpRecovery: marker есть, но blockchain_state отсутствует. blockchainName={}. Удаляем временные файлы.", + blockchainName); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; } - // ============================================================ - // CASE A) state ЕСТЬ - // ============================================================ long stateSize = st.getFileSizeBytes(); - // 1) stateSize == mainSize => tmp мусор - if (mainExists && mainSize == stateSize) { - log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}", - blockchainName, stateSize, mainSize, tmpSize); - safeDelete(tmpPath); - continue; - } + if (expectedBlockNumber == null || expectedBlockHashHex == null) { + log.warn("🟠 BlockchainTmpRecovery: write_check повреждён или пуст. blockchainName={}. Пробуем recovery по размерам.", + blockchainName); - // 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main - if (tmpSize == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}", - blockchainName, stateSize, mainSize, tmpSize); - - try { - // метод уже есть и делает move tmp->main с попыткой ATOMIC_MOVE - fs.atomicReplaceBlockchainFile(blockchainName); - - // после move tmp должен исчезнуть сам (перемещён) - log.info("✅ BlockchainTmpRecovery: восстановление выполнено. blockchainName={}, newMainSize={}", - blockchainName, safeSize(mainPath)); - - } catch (Exception e) { - BlockchainAdminNotifier.critical( - "НЕ УДАЛОСЬ ВОССТАНОВИТЬ main ИЗ tmp (move failed). " + - "blockchainName=" + blockchainName + - ", stateSize=" + stateSize + - ", mainSize=" + mainSize + - ", tmpSize=" + tmpSize + - ". СЕРВЕР ОСТАНОВЛЕН.", - e - ); - throw new IllegalStateException("Cannot replace main from tmp for " + blockchainName, e); + if (mainExists && mainSize == stateSize) { + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; } - continue; + if (tmpExists && tmpSize == stateSize) { + fs.atomicReplaceBlockchainFile(blockchainName); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; + } + if (tmpExists && mainExists) { + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; + } + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; } - // 3) НИЧЕГО НЕ СОВПАЛО => критическая ситуация + BlockEntry block = blocksDAO.getByNumber(blockchainName, expectedBlockNumber); + if (block == null || block.getBlockHash() == null) { + log.warn("🟠 BlockchainTmpRecovery: в blocks нет ожидаемого блока. blockchainName={}, blockNumber={}. Чистим временные файлы.", + blockchainName, expectedBlockNumber); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; + } + + String actualHashHex = HEX.formatHex(block.getBlockHash()); + if (!actualHashHex.equalsIgnoreCase(expectedBlockHashHex)) { + log.warn("🟠 BlockchainTmpRecovery: hash в write_check не совпал с DB. blockchainName={}, expected={}, actual={}. Чистим временные файлы.", + blockchainName, expectedBlockHashHex, actualHashHex); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; + } + + // Если main уже совпадает со state — tmp/check/marker лишние. + if (mainExists && mainSize == stateSize) { + log.info("🟢 BlockchainTmpRecovery: main уже соответствует state. blockchainName={}, stateSize={}, mainSize={}", + blockchainName, stateSize, mainSize); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; + } + + // Если tmp уже готов и совпадает со state — просто ставим его на место main. + if (tmpExists && tmpSize == stateSize) { + log.warn("🟡 BlockchainTmpRecovery: tmp соответствует state, восстанавливаем main. blockchainName={}, stateSize={}, tmpSize={}", + blockchainName, stateSize, tmpSize); + fs.atomicReplaceBlockchainFile(blockchainName); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; + } + + // Если tmp нет, но DB уже закоммитила блок — пробуем восстановить tmp из main + block_bytes. + if (!tmpExists && mainExists) { + long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length; + if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) { + log.warn("🟡 BlockchainTmpRecovery: tmp отсутствует, но main+DB дают валидный кандидат. blockchainName={}. Восстанавливаем tmp и main.", + blockchainName); + byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes()); + fs.writeBlockchainTmp(blockchainName, rebuilt); + fs.atomicReplaceBlockchainFile(blockchainName); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; + } + } + + // Если tmp есть, но его размер не совпал, пробуем восстановить из main + block_bytes. + if (mainExists) { + long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length; + if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) { + log.warn("🟡 BlockchainTmpRecovery: tmp/size не совпали, пересобираем tmp из main+block_bytes. blockchainName={}", + blockchainName); + byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes()); + fs.writeBlockchainTmp(blockchainName, rebuilt); + fs.atomicReplaceBlockchainFile(blockchainName); + cleanupWriteArtifacts(markerPath, checkPath, tmpPath); + return; + } + } + + // Если ничего не совпало, это уже подозрительное состояние. BlockchainAdminNotifier.critical( - "ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " + - "blockchainName=" + blockchainName + + "НЕСОГЛАСОВАННОСТЬ ОПЕРАЦИИ AddBlock ПРИ СТАРТЕ. blockchainName=" + blockchainName + ", stateSize=" + stateSize + ", mainExists=" + mainExists + ", mainSize=" + mainSize + + ", tmpExists=" + tmpExists + ", tmpSize=" + tmpSize + - ". СЕРВЕР ОСТАНОВЛЕН. " + - "ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.", + ", expectedBlockNumber=" + expectedBlockNumber + + ", expectedBlockHash=" + expectedBlockHashHex + + ". Требуется ручная проверка.", null ); - throw new IllegalStateException("Blockchain files mismatch for " + blockchainName); + throw new IllegalStateException("AddBlock recovery mismatch for " + blockchainName); + } catch (SQLException e) { + BlockchainAdminNotifier.critical( + "ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ AddBlock marker: blockchainName=" + blockchainName, + e + ); + throw new IllegalStateException("DB error during AddBlock recovery for " + blockchainName, e); } - - log.info("✅ BlockchainTmpRecovery: обработка tmp-файлов завершена."); } - /* ===================================================================== */ - /* =============================== Helpers ============================== */ - /* ===================================================================== */ + private static void cleanupOrphanTempArtifacts(Path dataDir, FileStoreUtil fs) { + List tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION); + List checkFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION); + + if (tmpFiles.isEmpty() && checkFiles.isEmpty()) { + return; + } + + log.warn("🟡 BlockchainTmpRecovery: найдено orphan tmp/check файлов. tmp={}, check={}", tmpFiles.size(), checkFiles.size()); + for (Path tmp : tmpFiles) { + String blockchainName = extractBlockchainName(tmp.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION); + if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) { + continue; + } + safeDelete(tmp); + } + for (Path check : checkFiles) { + String blockchainName = extractBlockchainName(check.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION); + if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) { + continue; + } + safeDelete(check); + } + } + + private static void cleanupWriteArtifacts(Path markerPath, Path checkPath, Path tmpPath) { + safeDelete(markerPath); + safeDelete(checkPath); + safeDelete(tmpPath); + } + + private static byte[] rebuildTmpFromMainAndBlock(Path mainPath, byte[] blockBytes) { + try { + byte[] mainBytes = Files.exists(mainPath) ? Files.readAllBytes(mainPath) : new byte[0]; + byte[] out = new byte[mainBytes.length + blockBytes.length]; + System.arraycopy(mainBytes, 0, out, 0, mainBytes.length); + System.arraycopy(blockBytes, 0, out, mainBytes.length, blockBytes.length); + return out; + } catch (IOException e) { + throw new IllegalStateException("Cannot rebuild tmp from main: " + mainPath, e); + } + } + + private static Map parseKeyValueFile(Path path) { + Map result = new HashMap<>(); + if (path == null || !Files.exists(path)) { + return result; + } + try { + for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { + if (line == null) continue; + String s = line.trim(); + if (s.isEmpty() || s.startsWith("#")) continue; + int idx = s.indexOf('='); + if (idx <= 0) continue; + String key = s.substring(0, idx).trim(); + String value = s.substring(idx + 1).trim(); + result.put(key, value); + } + return result; + } catch (IOException e) { + throw new IllegalStateException("Cannot read write_check file: " + path, e); + } + } + + private static Integer parseInt(String value) { + if (value == null || value.isBlank()) return null; + try { + return Integer.parseInt(value.trim()); + } catch (Exception e) { + return null; + } + } + + private static String normalizeHex(String value) { + if (value == null) return null; + String s = value.trim(); + return s.isEmpty() ? null : s; + } private static void ensureDirExists(Path dir) { try { @@ -206,31 +306,27 @@ public final class BlockchainTmpRecoveryOnStartup { } } - private static List listTmpFiles(Path dataDir) { + private static List listFilesWithSuffix(Path dataDir, String suffix) { List out = new ArrayList<>(); - try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) { + try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + suffix)) { for (Path p : ds) { - if (Files.isRegularFile(p)) out.add(p); + if (Files.isRegularFile(p)) { + out.add(p); + } } } catch (IOException e) { - throw new IllegalStateException("Cannot list tmp files in: " + dataDir, e); + throw new IllegalStateException("Cannot list files in: " + dataDir + " suffix=" + suffix, e); } return out; } - /** - * Из "anya0001.tmp_bch" -> "anya0001" - */ - private static String extractBlockchainNameFromTmp(String tmpFileName) { - if (tmpFileName == null) return null; - if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null; - - String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length()); - - // базовая защита: не допускаем слэши/.. даже если кто-то подложил файл + private static String extractBlockchainName(String fileName, String suffix) { + if (fileName == null) return null; + String s = fileName.trim(); + if (!s.endsWith(suffix)) return null; + String base = s.substring(0, s.length() - suffix.length()); if (base.isBlank()) return null; if (base.contains("/") || base.contains("\\") || base.contains("..")) return null; - return base; } @@ -249,4 +345,4 @@ public final class BlockchainTmpRecoveryOnStartup { throw new IllegalStateException("Cannot delete file: " + p, e); } } -} \ No newline at end of file +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index 01f4884..d318b2e 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -6,6 +6,9 @@ import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerI import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.debug.DebugApiConfigurator; +import server.sync.BlockchainResyncRecoveryOnStartup; +import server.sync.PeriodicBlockchainSyncService; +import server.sync.SyncServersBootstrapService; import utils.config.AppConfig; import java.time.Duration; @@ -36,6 +39,16 @@ public final class WsServer { throw e; // останавливаем запуск } + // ============================================================ + // 0.1) Восстановление цепочек, зависших на full resync + // ============================================================ + try { + BlockchainResyncRecoveryOnStartup.runRecoveryOrThrow(); + } catch (Exception e) { + log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain resync-маркеров.", e); + throw e; + } + // ============================================================ // 1) Настройки порта // ============================================================ @@ -50,6 +63,16 @@ public final class WsServer { log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port); } + // ============================================================ + // 1.1) Загрузка списка серверов синхронизации из Solana PDA + // ============================================================ + SyncServersBootstrapService.refreshFromSolanaOrLog(); + + // ============================================================ + // 1.2) Плановый межсерверный sync блокчейнов + // ============================================================ + PeriodicBlockchainSyncService.startOrLog(); + // ============================================================ // 2) Запуск Jetty WS // ============================================================ diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index c6fde35..307739f 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -1,5 +1,21 @@ server.1port=7070 db.path=data/shine.sqlite +server.SHiNE.login=shineupme +solana.cluster=mainnet-beta +solana.rpcUrl=https://api.mainnet-beta.solana.com + +# ------------------------------------------------------------ +# Межсерверная синхронизация: как создавать локальную запись пользователя, +# если во время sync пришла чужая цепочка, а у нас такого login ещё нет. +# false - брать профиль пользователя напрямую из Solana PDA (обычный режим). +# true - не ходить в Solana RPC, а запрашивать у сервера-партнёра специальный +# sync-профиль пользователя и по нему локально создавать +# solana_users + blockchain_state. +# Эта настройка нужна как временный обход лимитов Solana RPC (например 429), +# чтобы чистый сервер мог восстановить цепочки от партнёра без зависимости +# от внешнего Solana endpoint. +# ------------------------------------------------------------ +sync.importUserProfileFromPartner.enabled=false # ------------------------------------------------------------ # Server public info @@ -28,7 +44,7 @@ webpush.vapid.subject=mailto:admin@shine.local # Тогда сервер будет выдавать временный username/password (TTL). # ------------------------------------------------------------ call.ice.stun.urls=stun:stun.l.google.com:19302 -call.ice.turn.urls=turn:37.214.58.208:3478?transport=udp,turn:37.214.58.208:3478?transport=tcp +call.ice.turn.urls=turn:185.229.109.118:3478?transport=udp,turn:185.229.109.118:3478?transport=tcp call.ice.turn.ttlSec=600 call.ice.turn.userPrefix=shine call.ice.turn.sharedSecret= @@ -42,18 +58,12 @@ call.ice.turn.password= # Каждый блок описывает один TURN-узел. Новые узлы добавляются по индексу. # Приоритет авторизации на узел: sharedSecret -> статические username/password. # ------------------------------------------------------------ -call.ice.turn.servers.1.id=promo-node-93 -call.ice.turn.servers.1.urls=turn:93.170.12.154:3478?transport=udp,turn:93.170.12.154:3478?transport=tcp +call.ice.turn.servers.1.id=shineup-main-185 +call.ice.turn.servers.1.urls=turn:185.229.109.118:3478?transport=udp,turn:185.229.109.118:3478?transport=tcp call.ice.turn.servers.1.sharedSecret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106 call.ice.turn.servers.1.username= call.ice.turn.servers.1.password= -call.ice.turn.servers.2.id=shineup-main-185 -call.ice.turn.servers.2.urls=turn:185.229.109.118:3478?transport=udp,turn:185.229.109.118:3478?transport=tcp -call.ice.turn.servers.2.sharedSecret=def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106 -call.ice.turn.servers.2.username= -call.ice.turn.servers.2.password= - # ------------------------------------------------------------ # Временные debug HTTP API для тестирования соединений # true - endpoint'ы /debug/ws/* включены (только при наличии .debug-token) diff --git a/TODO/2026-06-26_1800_корректное_завершение_за_30с.md b/TODO/2026-06-26_1800_корректное_завершение_за_30с.md new file mode 100644 index 0000000..06ca5f9 --- /dev/null +++ b/TODO/2026-06-26_1800_корректное_завершение_за_30с.md @@ -0,0 +1,40 @@ +# Graceful shutdown на 30 секунд + +## Зачем + +Чтобы при `restart` или `stop` сервер получал небольшой запас времени на завершение опасных операций: + +- текущий `AddBlock`; +- full resync цепочки; +- очистку marker-file; +- дописывание временных файлов. + +Это должно уменьшить число случаев, когда приходится потом добирать хвост recovery-логикой. + +## Что сделать + +1. На стороне `systemd` задать `TimeoutStopSec=30s`. +2. В коде добавить корректную обработку shutdown-сигнала: + - перестать принимать новые блоки и sync-задачи; + - дождаться текущих операций; + - выйти в пределах таймаута. +3. При необходимости закрывать текущие ресурсы аккуратно: + - сетевые соединения; + - фоновые executor-ы; + - временные marker-файлы. + +## Что уже есть + +- `BlockchainTmpRecoveryOnStartup` и `BlockchainResyncRecoveryOnStartup` уже умеют добирать незавершённые хвосты после старта. +- `AddBlock` уже стал crash-safe через `tmp_bch` / `write_check` / `write_pending`. + +## Откуда продолжать + +- начать с `systemd`-юнита и базового shutdown-hook в сервере; +- затем проверить, что текущие операции реально завершаются в отведённые 30 секунд. + +## Какие документы потом обновить + +- `Dev_Docs/deploy/`; +- `Dev_Docs/Blockchain/sync-between-servers.md`, если изменится поведение остановки/восстановления. + diff --git a/TODO/2026-06-26_1805_межсерверный_ws_и_dm_sync.md b/TODO/2026-06-26_1805_межсерверный_ws_и_dm_sync.md new file mode 100644 index 0000000..5e59f1a --- /dev/null +++ b/TODO/2026-06-26_1805_межсерверный_ws_и_dm_sync.md @@ -0,0 +1,41 @@ +# Постоянный server-to-server WS и DM sync + +## Зачем + +Сейчас синхронизация между серверами работает в основном как periodic sync и one-shot push. Для нормальной репликации ещё нужен постоянный межсерверный канал: + +- живое подключение к партнёру; +- push новых блоков; +- push DM; +- ACK на доставку; +- backoff/reconnect; +- стартовый backfill. + +## Что сделать + +1. Поднять постоянное WebSocket-соединение между партнёрскими серверами. +2. Сделать push новых блоков сразу после `AddBlock`. +3. Сделать push DM-блоков между серверами. +4. Добавить ACK и повторную отправку при сбое. +5. Ввести стартовый обмен курсорами и добор хвоста. + +## Что уже есть + +- `ListBlockchainHeads`; +- `GetBlockchainBlock`; +- `GetSyncUserProfile`; +- базовый periodic sync; +- базовый backfill хвоста; +- базовый full resync при divergence. + +## Откуда продолжать + +- от текущего `sync_servers` bootstrap и `PeriodicBlockchainSyncService`; +- дальше выделить отдельный межсерверный transport layer. + +## Какие документы потом обновить + +- `Dev_Docs/Blockchain/sync-between-servers.md`; +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`; +- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`; +- `Dev_Docs/API/`. diff --git a/TODO/2026-06-26_1810_подключение_устройств_по_qr.md b/TODO/2026-06-26_1810_подключение_устройств_по_qr.md new file mode 100644 index 0000000..73b4f6b --- /dev/null +++ b/TODO/2026-06-26_1810_подключение_устройств_по_qr.md @@ -0,0 +1,29 @@ +# Подключение других устройств по QR и типизированные сессии + +## Зачем + +QR-подключение других устройств сейчас есть как заготовка, но сценарий нужно довести до устойчивого состояния. Параллельно надо аккуратно оформить типизированные сессии homeserver-ов в PDA. + +## Что сделать + +1. Довести QR-сценарий до стабильного подключения нового устройства. +2. Нормально описать и хранить устройство как отдельную типизированную сессию. +3. Согласовать это с серверной и UI-логикой. +4. Проверить, что подключение работает одинаково на новом и повторном устройстве. + +## Что уже есть + +- в планах есть `сессионные homeserver-ы в PDA`; +- в планах есть `подключение других устройств через QR`; +- базовая заготовка уже существует, но сценарий считается нестабильным. + +## Откуда продолжать + +- от текущих документов в `TODO/medium/`; +- отдельно проверить, какие поля уже есть в PDA и UI. + +## Какие документы потом обновить + +- `Dev_Docs/Solana_Architecture/README.md`; +- `TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md`; +- `TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md`. diff --git a/TODO/2026-06-26_1815_esp32_файловое_хранилище.md b/TODO/2026-06-26_1815_esp32_файловое_хранилище.md new file mode 100644 index 0000000..a1aadca --- /dev/null +++ b/TODO/2026-06-26_1815_esp32_файловое_хранилище.md @@ -0,0 +1,28 @@ +# ESP32 как личное файловое хранилище + +## Зачем + +Планируется использовать ESP32 как личное файловое хранилище SHiNE для переписок и вложений. + +## Что сделать + +1. Продумать формат хранения файлов на устройстве. +2. Согласовать загрузку и чтение файлов между UI, сервером и устройством. +3. Проверить, как устройство показывает статусы и ошибки. +4. Свести это с существующим homeserver/UI-прототипом. + +## Что уже есть + +- в списке будущих фич уже есть отдельная задача по ESP32S3 file storage; +- для UI homeserver уже есть отдельная документация и скетч должны держаться синхронно. + +## Откуда продолжать + +- от `TODO/medium/2026-05-26_0029_esp32s3_file_storage.md`; +- от документации по ESP32 UI homeserver. + +## Какие документы потом обновить + +- `TODO/medium/2026-05-26_0029_esp32s3_file_storage.md`; +- `TODO/README.md`; +- документацию по ESP32 UI homeserver, если добавятся экраны или статусы. diff --git a/Dev_Docs/Future_Features/README.md b/TODO/README.md similarity index 50% rename from Dev_Docs/Future_Features/README.md rename to TODO/README.md index 68bccfd..1156275 100644 --- a/Dev_Docs/Future_Features/README.md +++ b/TODO/README.md @@ -1,48 +1,45 @@ -# Будущие фичи +# TODO -Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку без отдельной команды пользователя. - -## Горизонты планирования - -- `near/` - ближайшие планы: задачи, к которым можно вернуться сегодня или завтра. -- `medium/` - среднесрочные планы: задачи на ближайшие недели или 1-2 месяца. -- `far/` - дальнее будущее: идеи без понятного срока возврата. - -Если пользователь спрашивает, какие есть планы, агент должен смотреть эти три папки и кратко перечислять задачи по горизонтам. +Папка для короткого списка ближайших и среднесрочных задач, которые уже обсуждались и пока отложены. ## Как использовать -1. Каждая будущая фича описывается отдельным markdown-файлом в одном из горизонтов. -2. В файле нужно фиксировать: - - зачем нужна фича; - - к какому сроку или горизонту она относится; - - что нужно сделать; - - какие вопросы нужно уточнить перед реализацией; - - что уже было сделано в коде, если фича частично реализована; - - что временно отключено или закомментировано, если применимо; - - какие документы нужно обновить при возврате к задаче; - - с какого места продолжать разработку. -3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя. +- Один markdown-файл = одна задача. +- В файле коротко фиксируем: + - зачем это нужно; + - что именно сделать; + - что уже есть в коде; + - откуда продолжать; + - какие документы потом надо обновить. +- Это не активная разработка. Тут только план и контекст. +- Старую папку `Dev_Docs/Future_Features/` считать архивной и больше не использовать как источник новых задач. -## Текущие планы +## Текущие задачи -### Ближайшие +- `2026-06-26_1800_корректное_завершение_за_30с.md` - дать сервису до 30 секунд на корректное завершение опасных операций перед рестартом. +- `2026-06-26_1805_межсерверный_ws_и_dm_sync.md` - постоянный server-to-server WebSocket, push новых блоков и DM, ACK и backfill. +- `2026-06-26_1810_подключение_устройств_по_qr.md` - довести подключение других устройств по QR и перевести это в нормальные типизированные сессии. +- `2026-06-26_1815_esp32_файловое_хранилище.md` - использовать ESP32 как личное файловое хранилище для переписок и вложений. + +## Перенесённые планы из `Dev_Docs/Future_Features/` + +### near - `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал. - `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса. -### Среднесрочные +### medium - `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах. - `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн. - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. -- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. - `medium/2026-06-02_сессионные_homeserver_в_pda.md` - несколько homeserver-ов пользователя как типизированные сессии в PDA с версией записи. +- `medium/2026-06-03_подключение_других_устройств_через_qr.md` - довести подключение других устройств через QR: сейчас заготовка есть, но сценарий работает нестабильно и его нужно будет отдельно доделать. -### DAO-запуск +### dao_запуск - `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона. -### Дальнее будущее +### far - `far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md` - технические команды для homeserver через SHiNE/WebRTC DataChannel и обмен файлами по чанкам с адресацией по `SHA-256`. diff --git a/Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md b/TODO/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md similarity index 100% rename from Dev_Docs/Future_Features/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md rename to TODO/dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md diff --git a/Dev_Docs/Future_Features/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md b/TODO/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md similarity index 97% rename from Dev_Docs/Future_Features/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md rename to TODO/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md index 1fa0c5e..4969ec5 100644 --- a/Dev_Docs/Future_Features/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md +++ b/TODO/far/2026-06-20_1639_homeserver_technical_commands_and_file_transfer.md @@ -106,7 +106,7 @@ - `Dev_Docs/Blockchain/README.md` и связанные файлы, если изменятся типы служебных сообщений или форматы блокчейн-команд. - `Dev_Docs/API/` если изменится публичный серверный API или появятся новые операции. -- `Dev_Docs/Personal_Messages/README.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий. +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` если часть маршрутизации или подтверждений будет встроена в существующую логику доставки/сессий. - Документацию по homeserver/ESP32, если появится пользовательская или сервисная файловая логика на устройстве. ## С какого места продолжать позже diff --git a/Dev_Docs/Future_Features/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md b/TODO/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md similarity index 98% rename from Dev_Docs/Future_Features/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md rename to TODO/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md index 641ff28..d5098db 100644 --- a/Dev_Docs/Future_Features/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md +++ b/TODO/medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md @@ -86,7 +86,7 @@ - `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/` как фичу, требующую ручной проверки. +9. После реализации перенести задачу из `TODO/` в `Dev_Docs/Pending_Features/` как фичу, требующую ручной проверки. ## Минимальный чек-лист ручной проверки в будущем diff --git a/Dev_Docs/Future_Features/medium/2026-05-25_1106_shine_balance_wallet.md b/TODO/medium/2026-05-25_1106_shine_balance_wallet.md similarity index 100% rename from Dev_Docs/Future_Features/medium/2026-05-25_1106_shine_balance_wallet.md rename to TODO/medium/2026-05-25_1106_shine_balance_wallet.md diff --git a/Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md b/TODO/medium/2026-05-26_0029_esp32s3_file_storage.md similarity index 98% rename from Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md rename to TODO/medium/2026-05-26_0029_esp32s3_file_storage.md index 9aadb9d..f2b1cb9 100644 --- a/Dev_Docs/Future_Features/medium/2026-05-26_0029_esp32s3_file_storage.md +++ b/TODO/medium/2026-05-26_0029_esp32s3_file_storage.md @@ -35,7 +35,7 @@ ## Документы, которые нужно обновить при возврате - `Dev_Docs/Keys/README.md` -- `Dev_Docs/Personal_Messages/README.md` +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` - `Dev_Docs/API/` - `Dev_Docs/Blockchain/`, если появятся новые блоки или команды для файлов. diff --git a/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md b/TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md similarity index 98% rename from Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md rename to TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md index b8f92d3..5948988 100644 --- a/Dev_Docs/Future_Features/medium/2026-06-02_сессионные_homeserver_в_pda.md +++ b/TODO/medium/2026-06-02_сессионные_homeserver_в_pda.md @@ -87,7 +87,7 @@ - `Dev_Docs/Solana_Architecture/README.md` - `Dev_Docs/Инициализация_Solana_регистрации/README.md` - `Dev_Docs/Keys/README.md` -- `Dev_Docs/Personal_Messages/README.md`, если изменится адресация DM по типам сессий +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`, если изменится адресация DM по типам сессий - `Dev_Docs/API/`, если появятся новые серверные операции или изменятся ответы ## Что пока не делать diff --git a/Dev_Docs/Future_Features/medium/2026-06-03_подключение_других_устройств_через_qr.md b/TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md similarity index 100% rename from Dev_Docs/Future_Features/medium/2026-06-03_подключение_других_устройств_через_qr.md rename to TODO/medium/2026-06-03_подключение_других_устройств_через_qr.md diff --git a/TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md b/TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md new file mode 100644 index 0000000..2f695b6 --- /dev/null +++ b/TODO/medium/2026-06-28_send_signal_перенос_старых_сигналов.md @@ -0,0 +1,29 @@ +# Перенести старые сессионные сигналы на `SendSignal` + +## Контекст + +В проект добавлен новый общий межсессионный transport `SendSignal`. + +Первое текущее применение: + +- `remote AddBlock via homeserver session` + +Старые сценарии пока оставлены на прежнем транспорте, чтобы не ломать уже работающий код. + +## Что перенести позже + +1. Звонковые сигналы, которые сейчас идут через `CallSignalToSession`. +2. Старый wallet/ESP32 обмен, где технические команды всё ещё привязаны к call-like транспорту. +3. Остальные доверенные межсессионные команды одного пользователя. + +## Что важно учесть при переносе + +- не ломать обратную совместимость работающих звонков; +- сохранить текущую маршрутизацию по `sessionId`; +- договориться о едином `signalType`; +- отдельно описать миграцию клиентских обработчиков событий: + - `IncomingCallSignal` -> `IncomingSignal` + +## С какого сценария продолжать + +Начинать перенос со звонков, но только после отдельной ручной проверки того, что `SendSignal` стабильно отработал на `remote AddBlock`. diff --git a/Dev_Docs/Future_Features/near/2026-05-25_1106_wallet_topup_solana_arweave.md b/TODO/near/2026-05-25_1106_wallet_topup_solana_arweave.md similarity index 100% rename from Dev_Docs/Future_Features/near/2026-05-25_1106_wallet_topup_solana_arweave.md rename to TODO/near/2026-05-25_1106_wallet_topup_solana_arweave.md diff --git a/TODO/near/2026-07-01_2205_восстановить_полную_логику_shine_login_guard.md b/TODO/near/2026-07-01_2205_восстановить_полную_логику_shine_login_guard.md new file mode 100644 index 0000000..e1b3eee --- /dev/null +++ b/TODO/near/2026-07-01_2205_восстановить_полную_логику_shine_login_guard.md @@ -0,0 +1,182 @@ +# Восстановить полную логику `shine_login_guard` + +Статус: отложено. + +## Зачем это нужно + +Сейчас в проекте включена временная заглушка для регистрации коротких логинов: + +- on-chain `shine_login_guard` пропускает любые валидные логины длиной от `5` символов; +- словарная `premium/trademark`-классификация временно отключена; +- основной UI удерживает обычных пользователей от логинов `5..7` через временный код; +- это сделано только до внедрения полноценной схемы продажи/выдачи красивых имён и промокодов. + +Когда полноценная логика будет готова, нужно вернуть полный режим `shine_login_guard`. + +## Что именно было в полной версии + +Последняя полная словарная реализация зафиксирована в коммите: + +- `0240db5` — `Подготовка перед деплоем в mainnet` + +Ключевой файл полной реализации: + +- `shine-solana/shine/programs/shine_login_guard/src/lib.rs` + +Как быстро посмотреть старую версию: + +```bash +git show 0240db5:shine-solana/shine/programs/shine_login_guard/src/lib.rs +``` + +## Как работала старая логика + +Полная версия делала следующее: + +1. Нормализовала логин: + - удаляла `_`; + - требовала только ASCII alnum; + - приводила к lower-case; + - запрещала пустой логин и логин длиннее `20`. + +2. Пыталась разбить логин на `1..3` словарных слова: + - premium-слова; + - trademark-слова. + +3. Приоритеты классов были такие: + - если найден хотя бы один trademark-компонент -> `CLASS_TRADEMARK`; + - если найден только premium-набор -> `CLASS_PREMIUM`; + - если словарного матча нет и длина нормализованного логина `<= 7` -> `CLASS_PREMIUM`; + - иначе -> `CLASS_FREE`. + +4. Для разбивки использовался DFS/backtracking по префиксам строки. + +## Какие файлы участвовали в полной логике + +### Обязательно восстановить или проверить + +1. Код программы: + - `shine-solana/shine/programs/shine_login_guard/src/lib.rs` + +2. Генерация словаря: + - `shine-solana/shine/programs/shine_login_guard/build.rs` + +3. Словари: + - `shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/**/*.txt` + - `shine-solana/shine/programs/shine_login_guard/src/dictionaries/trademarks/**/*.txt` + +4. Документация программы: + - `shine-solana/shine/doc/programs/shine_login_guard.md` + +5. Архитектурная документация: + - `Dev_Docs/Solana_Architecture/README.md` + +6. UI-логика precheck: + - `shine-UI/js/pages/register-view.js` + - `shine-UI/js/pages/registration-payment-view.js` + +## Что именно сейчас временно удалено из `lib.rs` + +Временная упрощённая версия убрала: + +- `mod wordlist { include!(...) }` +- `MAX_WORDS_PER_LOGIN` +- `classify_split(...)` +- `is_premium_word(...)` +- `is_trademark_word(...)` +- словарную DFS-сегментацию +- fallback `len <= 7 => premium` в пользу временного `len < 5 => premium` + +Сейчас `classify(login)` делает только: + +1. нормализацию; +2. проверку минимальной длины `5`; +3. возврат `CLASS_FREE` для всего остального. + +## Как вернуть полную логику + +### Минимальный путь + +1. Взять старую версию `lib.rs` из коммита `0240db5`. +2. Вернуть в файл: + - `mod wordlist` + - `MAX_WORDS_PER_LOGIN` + - `classify_split` + - `is_premium_word` + - `is_trademark_word` +3. Вернуть старые тесты и при необходимости дописать новые. +4. Проверить, что `build.rs` всё ещё генерирует `generated_dictionary.rs` в прежнем формате. +5. Сверить актуальность словарей в `src/dictionaries`. +6. Обновить `shine_login_guard.md` обратно под словарную логику. +7. Обновить `Dev_Docs/Solana_Architecture/README.md`. + +### Проверка после возврата + +Нужно убедиться, что: + +- trademark-логины снова помечаются как `CLASS_TRADEMARK`; +- premium-логины снова помечаются как `CLASS_PREMIUM`; +- короткие логины `<= 7` без словарного совпадения снова считаются premium; +- UI больше не использует временный код для коротких логинов. + +## Что поменять в UI при возврате + +После возврата полной on-chain логики нужно убрать временный клиентский костыль: + +1. Из `shine-UI/js/pages/register-view.js` удалить: + - `TEMP_MIN_LOGIN_WITHOUT_PROMO` + - `TEMP_ABSOLUTE_MIN_LOGIN_LEN` + - `TEMP_PROMO_HASH_HEX` + - локальную проверку временного кода + - временные тексты про код для логинов `5..7` + +2. В `shine-UI/js/pages/registration-payment-view.js` пересмотреть временную строку: + - сейчас `promoCode: ''` передаётся принудительно; + - после внедрения нормальной promo/on-chain схемы это место надо вернуть к реальной логике промокодов. + +## Что учитывать по размеру программы + +Текущая временная версия сильно уменьшила размер `shine_login_guard.so`. + +При возврате полной словарной логики: + +- бинарь снова заметно вырастет; +- потребуется больший `programdata` rent; +- для апгрейда понадобится больший временный buffer. + +Перед апгрейдом стоит отдельно: + +1. собрать `.so`; +2. измерить размер; +3. проверить `solana rent `; +4. убедиться, что на кошельке достаточно SOL для upgrade buffer. + +## Что ещё проверить перед возвратом + +1. Не осталось ли временных пометок в документации и UI. +2. Не завязаны ли новые сценарии на допуск логинов длиной `5..7`. +3. Готова ли полноценная схема: + - продажа красивых имён; + - промокоды; + - менеджеры/селлеры; + - DAO-квоты; + - или другая финальная модель, которая заменит временный режим. + +## Откуда продолжать + +Практически продолжать так: + +```bash +git show 0240db5:shine-solana/shine/programs/shine_login_guard/src/lib.rs +git show 0240db5:shine-solana/shine/programs/shine_login_guard/build.rs +git show 0240db5:shine-solana/shine/doc/programs/shine_login_guard.md +``` + +Потом: + +1. вернуть код; +2. обновить UI; +3. обновить документы; +4. собрать `cargo test`; +5. собрать `cargo build-sbf`; +6. только потом делать апгрейд on-chain программы. diff --git a/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md b/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md new file mode 100644 index 0000000..54a734c --- /dev/null +++ b/TODO/near/2026-07-07_дм_v11_убрать_временную_очистку_signed_messages.md @@ -0,0 +1,33 @@ +# Убрать временную очистку `signed_messages_v2` в миграции БД v11 + +## Зачем это нужно + +При переходе на новый DM-протокол `SHiNE_DM v1` была добавлена временная миграция БД `v11`, которая при первом старте на старой базе полностью очищает: + +- `signed_messages_v2` +- `signed_message_session_delivery` + +Это сделано как защитный reset, потому что старые DM-строки и backlog могли быть несовместимы с новым форматом, новой логикой tombstone и новым клиентским E2EE-разбором. + +## Что именно потом сделать + +- удалить временную миграцию `migrateToV11()` из [SqliteDbController.java](/home/ai/work/SHiNE/SHiNE-server-sha256/SHiNE-server/shine-server-db/src/main/java/shine/db/SqliteDbController.java); +- удалить helper `clearLegacySignedMessagesForDmV11(...)`; +- поднять версию схемы дальше обычным образом уже без destructive-cleanup; +- при необходимости заменить это на нормальную точечную миграцию старых DM-записей или совсем убрать поддержку старой истории. + +## Что уже есть в коде + +- `LATEST_SCHEMA_VERSION = 11`; +- при миграции в `v11` выполняется полная очистка DM-таблиц; +- в коде прямо оставлен комментарий, что это временная мера. + +## Откуда продолжать + +Продолжать от коммита, в котором была добавлена миграция `v11` для очистки DM-таблиц после перехода на `SHiNE_DM v1`. + +## Какие документы потом обновить + +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`, если изменится стратегия миграции старой истории; +- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`, если появится отдельное правило совместимости/конвертации; +- при необходимости `Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md`, если затронется поведение backlog/доставки. diff --git a/VERSION.properties b/VERSION.properties index 6ac26f1..ebc9a84 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.264 -server.version=1.2.246 +client.version=1.2.314 +server.version=1.2.292 diff --git a/build.gradle b/build.gradle index 6c17c5e..42fb27b 100644 --- a/build.gradle +++ b/build.gradle @@ -212,7 +212,7 @@ tasks.register('deployUIProduction', Exec) { tasks.register('deployServer', Exec) { group = "!!deployment" - description = "Default deploy server: test2.shineup.me" + description = "Default deploy server: t.shineup.me" dependsOn shadowJar workingDir = rootDir environment 'LOCAL_JAR', file('SHiNE-server/build/libs/shine-server.jar').absolutePath @@ -221,20 +221,20 @@ tasks.register('deployServer', Exec) { tasks.register('deployUI', Exec) { group = "!!deployment" - description = "Default deploy UI: test2.shineup.me" + description = "Default deploy UI: t.shineup.me" workingDir = rootDir commandLine 'bash', file('deploy_shine-ui_test2.sh').absolutePath } tasks.register('deployServerTest2') { group = "!!deployment" - description = "Явный алиас основного test deploy server: test2.shineup.me" + description = "Явный алиас основного test deploy server: t.shineup.me" dependsOn tasks.named('deployServer') } tasks.register('deployUITest2') { group = "!!deployment" - description = "Явный алиас основного test deploy UI: test2.shineup.me" + description = "Явный алиас основного test deploy UI: t.shineup.me" dependsOn tasks.named('deployUI') } diff --git a/deploy_shine-PWA.sh b/deploy_shine-PWA.sh index d857555..00cbb38 100755 --- a/deploy_shine-PWA.sh +++ b/deploy_shine-PWA.sh @@ -26,6 +26,8 @@ export CLIENT_VERSION TARGET_URL="${TARGET_URL:-https://${EXPECTED_CADDY_SITE}}" REMOTE_DIR="${REMOTE_UI_DIR}" +DEPLOY_SERVER_LOGIN="${DEPLOY_SERVER_LOGIN:-}" +DEPLOY_SERVER_ADDRESS="${DEPLOY_SERVER_ADDRESS:-}" cleanup() { rm -rf "$TMP_DIR" @@ -42,6 +44,16 @@ echo "==> Client version from $VERSION_FILE: $CLIENT_VERSION" echo "==> Deploy target: $TARGET_URL ($REMOTE_DIR)" rsync -a "$SRC_DIR"/ "$TMP_DIR"/ +DEPLOY_CONFIG_FILE="$TMP_DIR/js/deploy-config.js" +if [[ -f "$DEPLOY_CONFIG_FILE" ]]; then + if [[ -n "$DEPLOY_SERVER_LOGIN" ]]; then + perl -0pi -e "s/export const defaultServerLogin = '.*?';/export const defaultServerLogin = '$DEPLOY_SERVER_LOGIN';/s" "$DEPLOY_CONFIG_FILE" + fi + if [[ -n "$DEPLOY_SERVER_ADDRESS" ]]; then + perl -0pi -e "s/export const defaultServerAddress = '.*?';/export const defaultServerAddress = '$DEPLOY_SERVER_ADDRESS';/s" "$DEPLOY_CONFIG_FILE" + fi +fi + INDEX_FILE="$TMP_DIR/index.html" if [[ ! -f "$INDEX_FILE" ]]; then echo "ERROR: index.html not found in staged UI: $INDEX_FILE" >&2 diff --git a/deploy_shine-server_test2.sh b/deploy_shine-server_test2.sh index baeca85..e5a1cf5 100644 --- a/deploy_shine-server_test2.sh +++ b/deploy_shine-server_test2.sh @@ -1,9 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -PROD_HOST="${PROD_HOST:-player@shineup.me}" TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}" -TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}" +TARGET_DOMAIN="${TARGET_DOMAIN:-t.shineup.me}" REMOTE_BASE="${REMOTE_BASE:-/home/player/SHiNE}" REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:-$REMOTE_BASE/shine-server}" REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}" @@ -11,8 +10,6 @@ REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}" REMOTE_UI_DIR="${REMOTE_UI_DIR:-$REMOTE_BASE/shine-ui}" REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:-shine-server}" LOCAL_JAR="${LOCAL_JAR:-SHiNE-server/build/libs/shine-server.jar}" -PROD_DATA_DIR="${PROD_DATA_DIR:-/home/player/SHiNE/shine-server/data}" -PROD_APP_PROPS="${PROD_APP_PROPS:-/home/player/SHiNE/shine-server/application.properties}" TMP_DIR="$(mktemp -d)" cleanup() { @@ -25,16 +22,10 @@ if [[ ! -f "$LOCAL_JAR" ]]; then exit 1 fi -ssh -o BatchMode=yes -o ConnectTimeout=20 "$PROD_HOST" "echo SSH OK" >/dev/null ssh -o BatchMode=yes -o ConnectTimeout=20 "$TARGET_HOST" "echo SSH OK" >/dev/null ssh "$TARGET_HOST" "sudo -n true" ssh "$TARGET_HOST" "java -version >/dev/null 2>&1" -mkdir -p "$TMP_DIR/data" -rsync -az --delete "$PROD_HOST:$PROD_DATA_DIR/" "$TMP_DIR/data/" -rsync -az "$PROD_HOST:$PROD_APP_PROPS" "$TMP_DIR/application.properties" -perl -0pi -e 's@^server\.ui\.indexPath=.*$@server.ui.indexPath=/home/player/SHiNE/shine-ui/index.html@m' "$TMP_DIR/application.properties" - cat >"$TMP_DIR/shine-server.service" < Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`. > Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока). @@ -81,6 +86,7 @@ - `bad_signature`, `signature_verify_failed` - `prev_line_block_not_found`, `bad_prev_line_hash` - `limit_exceeded` +- `chain_resync_in_progress` — цепочка временно заблокирована полным resync - `repost_disabled` — репосты временно отключены до будущей реализации - `internal_error` @@ -174,3 +180,60 @@ - сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`); - нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента); - нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`. + +--- + +## 9. `GetBlockchainBlock` + +### Назначение + +Публичное чтение одного конкретного блока из цепочки. + +Нужно для: + +- открытого чтения блокчейна по одному блоку; +- межсерверной синхронизации; +- восстановления/докачки отсутствующего хвоста цепочки. + +### JSON формат запроса + +`op = "GetBlockchainBlock"`. + +```json +{ + "op": "GetBlockchainBlock", + "requestId": "req-2001", + "payload": { + "blockchainName": "alice-001", + "blockNumber": 12 + } +} +``` + +Поля `payload`: + +- `blockchainName` — обязательно, формат `login-NNN`. +- `blockNumber` — обязательно, номер блока в цепочке, `>= 0`. + +### Успешный ответ + +```json +{ + "op": "GetBlockchainBlock", + "requestId": "req-2001", + "status": 200, + "ok": true, + "payload": { + "blockchainName": "alice-001", + "blockNumber": 12, + "blockHash": "9f0eaabbccddeeff00112233445566778899aabbccddeeff0011223344556677", + "blockBytesB64": "AAAB..." + } +} +``` + +### Ошибки + +- `400 / BAD_FIELDS` — некорректные `blockchainName` или `blockNumber`. +- `404 / BLOCK_NOT_FOUND` — такого блока нет. +- `500 / INTERNAL_ERROR` — внутренняя ошибка сервера. diff --git a/docs/API/05_Technical_Requests_API.md b/docs/API/05_Technical_Requests_API.md new file mode 100644 index 0000000..79604bc --- /dev/null +++ b/docs/API/05_Technical_Requests_API.md @@ -0,0 +1,615 @@ +# API для разработчиков: Технические запросы + +Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии. + +Сейчас здесь девять методов: + +- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения; +- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети; +- `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации; +- `GetSyncUserProfile` — межсерверный профиль пользователя для создания локальной цепочки без Solana RPC; +- `SendSignal` — общий межсессионный технический сигнал в одну конкретную сессию или сразу во все активные сессии пользователя; +- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков; +- `ClientErrorLog` — отправка клиентской ошибки в серверный лог; +- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер; +- `CallDeliveryReport` — диагностический отчёт клиента о доставке/установке звонка. + +Логика раздела такая: + +- `Ping` нужен для регулярной проверки, что соединение всё ещё живо; +- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла. +- `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону. +- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC. +- `SendSignal` нужен для доверенных межсессионных команд одного пользователя. Первое практическое применение — `remote AddBlock via homeserver session`, но формат задуман как общий transport на вырост. + +Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов. + +## 1. `Ping` + +### Назначение + +Служебный keep-alive запрос. + +Клиент может отправлять его периодически, чтобы: + +- поддерживать активное WebSocket-соединение; +- понимать, что сервер отвечает; +- при необходимости получать текущее серверное время. + +### Запрос + +```json +{ + "op": "Ping", + "requestId": "ping-001", + "payload": { + "ts": 1774700000123 + } +} +``` + +Поле `ts` в запросе необязательно для логики сервера. Сервер его не валидирует и не использует для принятия решения. + +### Успешный ответ + +```json +{ + "op": "Ping", + "requestId": "ping-001", + "status": 200, + "ok": true, + "payload": { + "ts": 1774700000456 + } +} +``` + +### Специфические коды ошибок `Ping` + +- У `Ping` нет специальных прикладных ошибок. +- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`. + +--- + +## 2. `GetServerInfo` + +### Назначение + +Запрос публичной информации о сервере. + +Он нужен клиенту для выбора сервера в децентрализованной сети. По этому запросу клиент может: + +- проверить, что сервер вообще доступен; +- показать URL и версию сервера; +- показать физический регион или адрес размещения; +- показать описание сервера; +- показать поле `origin` как комментарий о природе этого узла; +- показать дополнительную текстовую информацию. + +Этот запрос доступен без авторизации. + +### Источник данных + +- `version` берётся из Gradle build и подставляется в `application.properties`; +- остальные поля читаются из настроек сервера; +- если значение в конфиге не задано, сервер возвращает пустую строку. + +### Запрос + +```json +{ + "op": "GetServerInfo", + "requestId": "srv-001", + "payload": { + } +} +``` + +### Успешный ответ + +```json +{ + "op": "GetServerInfo", + "requestId": "srv-001", + "status": 200, + "ok": true, + "payload": { + "url": "wss://node.example.org/ws", + "version": "1.0", + "physicalRegion": "Грузия, Тбилиси", + "description": "Public community SHiNE node", + "origin": "Community-operated node", + "extraInfo": "IPv4 + IPv6; test federation enabled" + } +} +``` + +### Поля ответа + +- `url` — публичный URL сервера. +- `version` — версия сервера из Gradle build. +- `physicalRegion` — физический регион или адрес размещения сервера. +- `description` — человекочитаемое описание сервера. +- `origin` — комментарий о том, какой это сервер. +- `extraInfo` — любая дополнительная информация о сервере. + +### Специфические коды ошибок `GetServerInfo` + +- У `GetServerInfo` нет специальных прикладных ошибок при штатной работе. +- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`. + +--- + +## 3. `ListBlockchainHeads` + +### Назначение + +Запрос краткой сводки по всем локальным блокчейнам сервера. + +Нужен для межсерверной синхронизации. Партнёр может: + +- получить список всех блокчейнов; +- сравнить `lastBlockNumber` и `lastBlockHash` со своими значениями; +- понять, какие цепочки нужно догонять; +- затем отдельно запросить недостающие блоки по диапазону. + +Этот запрос доступен без авторизации. + +### Запрос + +```json +{ + "op": "ListBlockchainHeads", + "requestId": "heads-001", + "payload": {} +} +``` + +### Успешный ответ + +```json +{ + "op": "ListBlockchainHeads", + "requestId": "heads-001", + "status": 200, + "ok": true, + "payload": { + "blockchains": [ + { + "blockchainName": "alice_main", + "lastBlockNumber": 124, + "lastBlockHash": "aabbccdd00112233445566778899aabbccddeeff00112233445566778899aabb", + "fileSizeBytes": 58720 + } + ] + } +} +``` + +### Поля ответа + +- `blockchains` — массив текущих heads всех цепочек сервера. +- `blockchainName` — имя блокчейна. +- `lastBlockNumber` — последний номер блока в этой цепочке. +- `lastBlockHash` — последний хэш блока в HEX-формате `64` символа. +- `fileSizeBytes` — текущий размер файла блокчейна в байтах. + +### Специфические коды ошибок `ListBlockchainHeads` + +- У `ListBlockchainHeads` нет специальных прикладных ошибок при штатной работе. +- Если произойдёт непредвиденная проблема, сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`. + +--- + +## 4. `GetSyncUserProfile` + +### Назначение + +Запрос минимального профиля пользователя для межсерверной синхронизации. + +Нужен в сценарии, когда сервер во время periodic sync увидел чужой блокчейн, которого у него локально ещё нет. Вместо обращения в Solana PDA он может запросить у партнёра: + +- `login` +- `blockchainName` +- `solanaKey` +- `blockchainKey` +- `clientKey` +- `blockchainSizeLimitBytes` + +После этого принимающий сервер может локально создать записи в `solana_users` и `blockchain_state`, а затем уже докачивать блоки через `GetBlockchainBlock`. + +Этот запрос доступен без авторизации и предназначен именно для server-to-server sync. + +### Запрос + +```json +{ + "op": "GetSyncUserProfile", + "requestId": "sync-user-001", + "payload": { + "login": "alice" + } +} +``` + +### Успешный ответ: пользователь не найден + +```json +{ + "op": "GetSyncUserProfile", + "requestId": "sync-user-001", + "status": 200, + "ok": true, + "payload": { + "exists": false + } +} +``` + +### Успешный ответ: пользователь найден + +```json +{ + "op": "GetSyncUserProfile", + "requestId": "sync-user-001", + "status": 200, + "ok": true, + "payload": { + "exists": true, + "login": "alice", + "blockchainName": "alice-001", + "solanaKey": "BASE64_32", + "blockchainKey": "BASE64_32", + "clientKey": "BASE64_32", + "blockchainSizeLimitBytes": 100000 + } +} +``` + +### Поля ответа + +- `exists` — найден ли пользователь на сервере-партнёре. +- `login` — канонический login из БД сервера-партнёра. +- `blockchainName` — имя основной цепочки пользователя. +- `solanaKey` — публичный ключ логина. +- `blockchainKey` — публичный ключ блокчейна. +- `clientKey` — публичный клиентский ключ, который в текущей модели используется при создании локальной записи. +- `blockchainSizeLimitBytes` — лимит размера файла блокчейна, который будет записан в локальный `blockchain_state`. + +### Специфические коды ошибок `GetSyncUserProfile` + +- `400 / BAD_FIELDS` — пустой или некорректный `login`. +- `404 / BLOCKCHAIN_STATE_NOT_FOUND` — пользователь найден, но на сервере-партнёре отсутствует `blockchain_state` для его цепочки. +- При непредвиденной ошибке сервер вернёт общую ошибку из раздела `00`, обычно `500 / INTERNAL_ERROR`. + +--- + +## 5. `SendSignal` + +Доступно только после успешной авторизации. + +### Назначение + +Общий межсессионный технический сигнал. + +Этот метод нужен для случаев, когда одна активная сессия пользователя должна быстро передать служебную команду другой сессии того же пользователя или сразу всем его активным сессиям. + +Первый целевой сценарий: + +- `remote AddBlock via homeserver session` + +То есть телефон без локального `blockchain.key` может: + +- подготовить только сырой payload операции без текущей вершины цепочки; +- подписать сам `SendSignal` своим `session key`; +- дополнительно подписать его `client key`, чтобы homeserver/ESP32 точно видел, что запрос пришёл от доверенного клиента этого же логина; +- отправить запрос в выбранную `homeserver`-сессию; +- получить от неё ответ после настоящего `AddBlock`, который homeserver соберёт и подпишет уже сама. + +### Режимы доставки + +- `targetMode = "single_session"` — доставка в одну конкретную `targetSessionId`. +- `targetMode = "all_sessions"` — доставка во все активные сессии указанного логина. + +### Важное правило подписи + +Сам `SendSignal` не подписывает поле `data` отдельной вложенной подписью. Вместо этого сервер проверяет подписи по общему preimage сигнала, в который входит: + +- `fromLogin` +- `fromSessionId` +- `toLogin` +- `targetMode` +- `targetSessionId` +- `signalType` +- `signalRequestId` +- `timeMs` +- `sha256(data)` + +Поддерживаются две подписи: + +- `sessionSignatureB64` — обязательная подпись текущей авторизованной `session key`; +- `clientSignatureB64` — необязательная подпись `client key`. + +Для сценария `remote AddBlock via homeserver` текущая договорённость такая: + +- запрос должен идти только своему же логину; +- запрос должен быть подписан и `session key`, и `client key`; +- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой. + +### Запрос в одну сессию + +```json +{ + "op": "SendSignal", + "requestId": "ws-req-001", + "payload": { + "toLogin": "alice", + "targetMode": "single_session", + "targetSessionId": "sess-hs-001", + "signalType": "remote_addblock_request", + "signalRequestId": "remote-addblock-001", + "data": "{\"operation\":\"remote_addblock_request\",\"signalRequestId\":\"remote-addblock-001\",\"blockchainName\":\"alice_main\",\"blockBodyB64\":\"...\"}", + "timeMs": 1774700000123, + "sessionSignatureB64": "BASE64_64", + "clientSignatureB64": "BASE64_64" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "SendSignal", + "requestId": "ws-req-001", + "status": 200, + "ok": true, + "payload": { + "deliveredCount": 1, + "deliveredSessionIds": ["sess-hs-001"] + } +} +``` + +### Событие на принимающей стороне + +```json +{ + "op": "IncomingSignal", + "eventId": "evt-001", + "payload": { + "fromLogin": "alice", + "fromSessionId": "sess-phone-001", + "toLogin": "alice", + "targetMode": "single_session", + "targetSessionId": "sess-hs-001", + "signalType": "remote_addblock_request", + "signalRequestId": "remote-addblock-001", + "data": "{\"operation\":\"remote_addblock_request\",\"signalRequestId\":\"remote-addblock-001\",\"blockchainName\":\"alice_main\",\"blockBodyB64\":\"...\"}", + "timeMs": 1774700000123, + "sessionSignatureB64": "BASE64_64", + "clientSignatureB64": "BASE64_64", + "dataSha256B64": "BASE64_32" + } +} +``` + +### Специфика `remote AddBlock` + +Для `remote_addblock_request` поле `data` теперь содержит: + +- `blockchainName` +- `blockBodyB64` + +Где `blockBodyB64` — это не финальный блок и не почти готовый preimage, а компактный бинарный контейнер: + +- `msgType` (`u16`) +- `msgSubType` (`u16`) +- `msgVersion` (`u16`) +- `bodyBytes` + +После этого homeserver сама: + +- вызывает `GetUser(login)` и получает `serverLastGlobalNumber/serverLastGlobalHash`; +- вычисляет новый `blockNumber = last + 1`; +- подставляет актуальный `prevBlockHash`; +- ставит текущее время; +- досчитывает полный preimage; +- подписывает его своим `blockchain key`; +- и только потом делает настоящий `AddBlock`. + +### Специфические коды ошибок `SendSignal` + +- `422 / NOT_AUTHENTICATED` — требуется авторизация. +- `400 / BAD_FIELDS` — не хватает обязательных полей или нарушено правило `single_session/all_sessions`. +- `400 / BAD_TARGET_MODE` — передан неизвестный `targetMode`. +- `400 / TIME_SKEW` — `timeMs` отличается от серверного более чем на 30 секунд. +- `500 / NO_CLIENT_KEY` — для текущего пользователя не найден `client key`. +- `404 / USER_NOT_FOUND` — логин адресата не найден. +- `400 / BAD_DATA` — сервер не смог обработать `data`. +- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`. +- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`. +- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн. +- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий. +- `404 / DELIVERY_FAILED` — сервер не смог отправить событие ни в одну из целевых сессий. + +--- + +## 6. `GetCallIceConfig` + +Доступно только после успешной авторизации. + +### Запрос + +```json +{ + "op": "GetCallIceConfig", + "requestId": "ice-001", + "payload": { + } +} +``` + +### Успешный ответ + +```json +{ + "op": "GetCallIceConfig", + "requestId": "ice-001", + "status": 200, + "ok": true, + "payload": { + "stunUrls": ["stun:stun.example.org:3478"], + "turnUrls": ["turn:turn.example.org:3478?transport=udp"], + "turnUsername": "user", + "turnPassword": "password", + "turnServers": [ + { + "id": "primary", + "urls": ["turn:turn.example.org:3478?transport=udp"], + "username": "user", + "password": "password" + } + ], + "turnEnabled": true, + "generatedAtMs": 1774700000123, + "expiresAtMs": 1774700300123, + "ttlSec": 300 + } +} +``` + +### Специфические коды ошибок `GetCallIceConfig` + +- `422 / NOT_AUTHENTICATED` — требуется авторизация. + +--- + +## 7. `ClientErrorLog` + +### Запрос + +```json +{ + "op": "ClientErrorLog", + "requestId": "err-001", + "payload": { + "kind": "global_error", + "message": "TypeError: failed", + "stack": "...", + "sourceUrl": "https://shineup.me/app.js", + "lineNumber": 10, + "columnNumber": 20, + "route": "#/channel-view/own-0", + "href": "https://shineup.me/#/channel-view/own-0", + "userAgent": "...", + "clientTs": 1774700000123, + "requestOp": "GetChannelMessages", + "requestIdRef": "GetChannelMessages-123", + "contextJson": "{\"screen\":\"channels\"}" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "ClientErrorLog", + "requestId": "err-001", + "status": 200, + "ok": true, + "payload": { + "serverTs": 1774700000456, + "accepted": true + } +} +``` + +### Специфические коды ошибок `ClientErrorLog` + +- `400 / BAD_FIELDS` — обязательные поля ошибки не заполнены. + +--- + +## 8. `ClientDebugLog` + +### Запрос + +```json +{ + "op": "ClientDebugLog", + "requestId": "dbg-001", + "payload": { + "runId": "ui-run-1", + "level": "info", + "message": "opened channels tab", + "details": "{\"route\":\"#/channels\"}" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "ClientDebugLog", + "requestId": "dbg-001", + "status": 200, + "ok": true, + "payload": { + "accepted": true, + "serverTs": 1774700000456 + } +} +``` + +### Специфические коды ошибок `ClientDebugLog` + +- `400 / BAD_FIELDS` — поле `message` не заполнено. + +--- + +## 9. `CallDeliveryReport` + +### Запрос + +```json +{ + "op": "CallDeliveryReport", + "requestId": "call-report-001", + "payload": { + "type": "outgoing_failed", + "value": "{\"reason\":\"ice_failed\",\"callId\":\"call-1\"}" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "CallDeliveryReport", + "requestId": "call-report-001", + "status": 200, + "ok": true, + "payload": { + "serverTs": 1774700000456, + "accepted": true + } +} +``` + +### Специфические коды ошибок `CallDeliveryReport` + +- `400 / BAD_FIELDS` — поле `type` не заполнено. + +--- + +## 10. Короткое резюме + +- `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо. +- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле. +- `SendSignal` нужен для доверенных межсессионных сигналов одного пользователя, включая `remote AddBlock via homeserver session`. +- `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации. +- `ClientErrorLog`, `ClientDebugLog`, `CallDeliveryReport` используются для диагностики клиента и звонков. diff --git a/Dev_Docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md similarity index 100% rename from Dev_Docs/API/06_Channels_Read_API.md rename to docs/API/06_Channels_Read_API.md diff --git a/Dev_Docs/API/07_Channels_Feature_Runbook.md b/docs/API/07_Channels_Feature_Runbook.md similarity index 100% rename from Dev_Docs/API/07_Channels_Feature_Runbook.md rename to docs/API/07_Channels_Feature_Runbook.md diff --git a/Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md b/docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md similarity index 100% rename from Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md rename to docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md diff --git a/Dev_Docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md similarity index 87% rename from Dev_Docs/API/09_Operations_Index.md rename to docs/API/09_Operations_Index.md index 0b4e044..2fad3d5 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -32,8 +32,12 @@ | `ListSessions` | `03_Session_Management_API.md` | список активных сессий | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | +| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна | | `Ping` | `05_Technical_Requests_API.md` | keep-alive | | `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере | +| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов | +| `GetSyncUserProfile` | `05_Technical_Requests_API.md` | межсерверный профиль пользователя для синхронизации | +| `SendSignal` | `05_Technical_Requests_API.md` | общий межсессионный технический сигнал в одну или все сессии пользователя | | `GetCallIceConfig` | `05_Technical_Requests_API.md` | STUN/TURN конфигурация звонков | | `ClientErrorLog` | `05_Technical_Requests_API.md` | логирование клиентской ошибки | | `ClientDebugLog` | `05_Technical_Requests_API.md` | клиентский debug-лог | @@ -53,10 +57,11 @@ | `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга | | `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена | | `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка | -| `SendDirectMessage` | `12_Direct_Messages_Push_Calls_API.md` | отправка подписанного DM-пакета | | `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM | | `ReceiveOutcomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | алиас `SendMessagePair` | | `ReceiveIncomingMessage` | `12_Direct_Messages_Push_Calls_API.md` | прием входящего DM-блока | +| `DeleteMessage` | `12_Direct_Messages_Push_Calls_API.md` | tombstone одного личного сообщения у обеих сторон | +| `DeleteConversation` | `12_Direct_Messages_Push_Calls_API.md` | tombstone удаления истории переписки | | `AckSessionDelivery` | `12_Direct_Messages_Push_Calls_API.md` | подтверждение доставки в сессию | | `CallInviteBroadcast` | `12_Direct_Messages_Push_Calls_API.md` | broadcast приглашения к звонку | | `CallSignalToSession` | `12_Direct_Messages_Push_Calls_API.md` | сигнал звонка в конкретную сессию | @@ -64,6 +69,7 @@ ## Важные замечания - `ReceiveOutcomingMessage` сейчас зарегистрирован как алиас того же handler/request-класса, что и `SendMessagePair`. +- Legacy-операция `SendDirectMessage` больше не зарегистрирована и не должна использоваться для DM v1. - Отдельных HTTP endpoints для DM-файлов сейчас нет. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. diff --git a/Dev_Docs/API/10_User_Params_API.md b/docs/API/10_User_Params_API.md similarity index 100% rename from Dev_Docs/API/10_User_Params_API.md rename to docs/API/10_User_Params_API.md diff --git a/Dev_Docs/API/11_Connections_API.md b/docs/API/11_Connections_API.md similarity index 100% rename from Dev_Docs/API/11_Connections_API.md rename to docs/API/11_Connections_API.md diff --git a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md b/docs/API/12_Direct_Messages_Push_Calls_API.md similarity index 60% rename from Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md rename to docs/API/12_Direct_Messages_Push_Calls_API.md index 0cbac5e..f4a3b8f 100644 --- a/Dev_Docs/API/12_Direct_Messages_Push_Calls_API.md +++ b/docs/API/12_Direct_Messages_Push_Calls_API.md @@ -4,7 +4,13 @@ Подробная логика DM и бинарного формата: -- `Dev_Docs/Personal_Messages/README.md` +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` +- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` + +Важно: + +- legacy-операция `SendDirectMessage` отключена и не зарегистрирована в публичном API; +- для DM v1 нужно использовать только `SendMessagePair`, `ReceiveOutcomingMessage`, `ReceiveIncomingMessage`, `DeleteMessage`, `DeleteConversation`. ## 1. `UpsertPushToken` @@ -72,7 +78,7 @@ - `incomingBlobB64` — блок `type=1` или `type=3` - `outgoingBlobB64` — блок `type=2` или `type=4` -Для контентных сообщений `type=1/2` внутри base64 лежит бинарный формат `SHiNE_DM`. +Все типы в этой паре используют бинарный формат `SHiNE_DM`. ### Запрос @@ -109,9 +115,8 @@ - `400 / BAD_FIELDS` — пустой `incomingBlobB64` или `outgoingBlobB64` - `400 / BAD_BLOCK_FORMAT` — base64 или бинарный контейнер повреждён -- `400 / BAD_CONTENT_FORMAT` — для контентного сообщения пришёл не `SHiNE_DM` -- `400 / ATTACHMENTS_DISABLED` — в `SHiNE_DM` пришёл `attachmentsCount != 0` -- `404 / USER_NOT_FOUND` — один из логинов не найден +- `400 / BAD_CRYPTO_METHOD` — неподдерживаемый метод шифрования контейнера `EncryptedBody_v1_0` +- `404 / USER_NOT_FOUND` — один из логинов не найден даже после попытки lazy-import из Solana PDA - `460 / BAD_SIGNATURE` — подпись блока не прошла проверку ## 4. `ReceiveIncomingMessage` @@ -122,6 +127,11 @@ Используется там, где нужно принять только incoming-вариант сообщения. +Принимаемые типы: + +- `type=1` — входящая копия сообщения +- `type=3` — входящий read-receipt + ### Запрос ```json @@ -134,7 +144,39 @@ } ``` -## 5. `AckSessionDelivery` +## 5. `DeleteMessage` + +Принимает один signed DM-блок `type=5` или `type=6`. + +### Запрос + +```json +{ + "op": "DeleteMessage", + "requestId": "dm-del-001", + "payload": { + "blobB64": "BASE64_DELETE_BLOCK" + } +} +``` + +## 6. `DeleteConversation` + +Принимает один signed DM-блок `type=7` или `type=8`. + +### Запрос + +```json +{ + "op": "DeleteConversation", + "requestId": "dm-del-all-001", + "payload": { + "blobB64": "BASE64_DELETE_CONVERSATION_BLOCK" + } +} +``` + +## 7. `AckSessionDelivery` Требует авторизации. Подтверждает доставку в текущую сессию. @@ -150,7 +192,7 @@ } ``` -## 6. Событие `SignedMessageArrived` +## 8. Событие `SignedMessageArrived` Сервер присылает его по WebSocket в активные сессии адресата. @@ -173,18 +215,21 @@ Если это новая ревизия того же письма, `messageKey` остаётся тем же, а `revisionTimeMs` меняется внутри бинарного блока. -## 7. `CallInviteBroadcast` +Для типов `5/6/7/8` событие тоже приходит в таком же конверте, но логика применения определяется `messageType` и бинарным `blobB64`. + +## 9. `CallInviteBroadcast` Требует авторизации. Шлёт приглашение к звонку в активные сессии `toLogin`. -## 8. `CallSignalToSession` +## 10. `CallSignalToSession` Требует авторизации. Шлёт сигнал звонка в конкретную сессию. -## 9. Замечания +## 11. Замечания -- read-receipt `type=3/4` пока остаются в legacy-формате `SHiNE_dm2` -- контентные DM `type=1/2` используют `SHiNE_DM` -- сервер хранит только последнюю версию контентного сообщения по `messageKey` -- удаление сообщения реализуется новой ревизией с пустым телом и `attachmentsCount = 0` +- все DM-типы `1..8` используют `SHiNE_DM` +- `GetUser` может lazy-import пользователя из Solana PDA, поэтому именно через него клиент обычно получает `clientKey` адресата для E2EE +- сервер не расшифровывает DM и не использует ciphertext как preview текста +- сервер хранит последнюю применённую версию контентного сообщения по правилу `revisionTimeMs`, а при равенстве — по `reencryptedAtMs` +- если сервер уже знает tombstone удаления переписки и получает старое сообщение до этой границы, он перерассылает известный `DeleteConversation` на `access_servers` обеих сторон - HTTP endpoints для DM-файлов сейчас отсутствуют diff --git a/Dev_Docs/API/13_HTTP_Debug_API.md b/docs/API/13_HTTP_Debug_API.md similarity index 100% rename from Dev_Docs/API/13_HTTP_Debug_API.md rename to docs/API/13_HTTP_Debug_API.md diff --git a/Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md b/docs/API/14_Test_Free_Avatar_Upload_API.md similarity index 100% rename from Dev_Docs/API/14_Test_Free_Avatar_Upload_API.md rename to docs/API/14_Test_Free_Avatar_Upload_API.md diff --git a/Dev_Docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md similarity index 100% rename from Dev_Docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md rename to docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md diff --git a/Dev_Docs/Blockchain/01_Channel_Types_and_CreateChannel.md b/docs/Blockchain/01_Channel_Types_and_CreateChannel.md similarity index 100% rename from Dev_Docs/Blockchain/01_Channel_Types_and_CreateChannel.md rename to docs/Blockchain/01_Channel_Types_and_CreateChannel.md diff --git a/Dev_Docs/Blockchain/01_Common_Block_Format.md b/docs/Blockchain/01_Common_Block_Format.md similarity index 100% rename from Dev_Docs/Blockchain/01_Common_Block_Format.md rename to docs/Blockchain/01_Common_Block_Format.md diff --git a/Dev_Docs/Blockchain/02_Blockchain_Kinds_and_Lines.md b/docs/Blockchain/02_Blockchain_Kinds_and_Lines.md similarity index 100% rename from Dev_Docs/Blockchain/02_Blockchain_Kinds_and_Lines.md rename to docs/Blockchain/02_Blockchain_Kinds_and_Lines.md diff --git a/Dev_Docs/Blockchain/02_Channel_Commands.md b/docs/Blockchain/02_Channel_Commands.md similarity index 100% rename from Dev_Docs/Blockchain/02_Channel_Commands.md rename to docs/Blockchain/02_Channel_Commands.md diff --git a/Dev_Docs/Blockchain/10_TECH_Blocks.md b/docs/Blockchain/10_TECH_Blocks.md similarity index 100% rename from Dev_Docs/Blockchain/10_TECH_Blocks.md rename to docs/Blockchain/10_TECH_Blocks.md diff --git a/Dev_Docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md similarity index 100% rename from Dev_Docs/Blockchain/11_TEXT_Blocks.md rename to docs/Blockchain/11_TEXT_Blocks.md diff --git a/Dev_Docs/Blockchain/12_REACTION_Blocks.md b/docs/Blockchain/12_REACTION_Blocks.md similarity index 100% rename from Dev_Docs/Blockchain/12_REACTION_Blocks.md rename to docs/Blockchain/12_REACTION_Blocks.md diff --git a/Dev_Docs/Blockchain/13_CONNECTION_Blocks.md b/docs/Blockchain/13_CONNECTION_Blocks.md similarity index 100% rename from Dev_Docs/Blockchain/13_CONNECTION_Blocks.md rename to docs/Blockchain/13_CONNECTION_Blocks.md diff --git a/Dev_Docs/Blockchain/14_USER_PARAM_Blocks.md b/docs/Blockchain/14_USER_PARAM_Blocks.md similarity index 100% rename from Dev_Docs/Blockchain/14_USER_PARAM_Blocks.md rename to docs/Blockchain/14_USER_PARAM_Blocks.md diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md new file mode 100644 index 0000000..49f6946 --- /dev/null +++ b/docs/Blockchain/CHANGELOG.md @@ -0,0 +1,110 @@ +# История изменений документации блокчейна + +## 2026-07-14 20:43:43 +0400 +- Базовый коммит-ориентир: `118be41`. +- В `docs/ИТХ/Спецификация_ИТХ_v1.md` изменён магик файла чекпоинта: + вместо `SHNCKPT1` (8 байт) — читаемый `SHiNE-CHECKPOINT` (16 байт ASCII, + по образцу `SQLite format 3\0`). Версия формата в магик не входит — + она в поле `v` заголовка и в первой строке preimage. + +## 2026-07-14 19:56:50 +0400 +- Базовый коммит-ориентир: `172f007`. +- В `docs/ИТХ/Спецификация_ИТХ_v1.md` из TODO убран «вход в график по депозиту»: + вход в группу закрытий — по доверию, не за деньги. Зафиксирована целевая модель: + DAO ведёт список как временный уровень, пока не сформировано сообщество сияющих; + далее приём/исключение серверов — через голосование сияющих (правка в разделе 2 + и в TODO). + +## 2026-07-14 18:43:18 +0400 +- Базовый коммит-ориентир: `4cb98f7`. +- Дизайн ИТХ дополнен по итогам обсуждения (`docs/ИТХ/README.md`, `docs/ИТХ/Спецификация_ИТХ_v1.md`): + - правило признания явно объявлено правилом чтения: дефолт — rota PDA, но наблюдатель + может считать голоса по собственному списку доверия («запись — по общему списку, + чтение — по любому»); параллельные группы закрытий разрешены by design; + - новый раздел 10 «Форки пользовательских цепочек»: fork-choice дежурного + (длиннейшая ветка от зачекпоинченной вершины + tie-break по меньшему `hash32`), + зачекпоинченное всегда побеждает локальные ветки, заморозка цепочки (`chain_frozen`) + только при противоречии между архивами двух групп закрытий, рестарт с `login-002`; + - шаг 9.9 проверки изменён с «конфликт → отказ» на «конфликт → принять версию + чекпоинта»: один пользователь больше не может сорвать закрытие дня; + - правило для клиентов: повтор отправки блока — только байт-в-байт (дедупликация, + а не форк); + - TODO дополнен: детерминированный выбор дежурного через hash, вход в график + по депозиту, механизм новой цепочки `-002`. +- Формат блоков и правила `AddBlock` по-прежнему не изменены (код не реализован); + при реализации дописать `chain_frozen` в `docs/API/04_Add_Block_to_Blockchain_API.md`. + +## 2026-07-13 14:04:59 +0400 +- Базовый коммит-ориентир: `af12d7b`. +- Добавлена новая папка документации `docs/ИТХ/` — ежедневное закрытие блокчейна (чекпоинты): + - `docs/ИТХ/README.md` — краткий обзор для людей; + - `docs/ИТХ/Спецификация_ИТХ_v1.md` — подробная спецификация для реализации + (формат чекпоинта в Arweave, меркл-правила ИТХ, rota PDA DAO, слот `CheckpointStateBlock` + в PDA серверов, канал закрытий с командой `/.checkpoint`, алгоритмы дежурного и проверяющего). +- Формат блоков и правила `AddBlock` не изменены: это новый мета-слой поверх существующего блокчейна. +- Код пока не реализован; при реализации обязательно дописать `/.checkpoint` + в `02_Channel_Commands.md` и `CheckpointStateBlock` в формат `user_pda`. +- В `README.md` этого каталога добавлен раздел «Смежная документация» со ссылками на `docs/ИТХ/`. + +# 2026-06-26 17:45:18 +0400 +- Базовый коммит-ориентир: `44a1ba0`. +- На `t.shineup.me` подтверждена рабочая схема startup sync и full-resync: + - после рестарта сервер добивает `BlockchainTmpRecovery` и `BlockchainResyncRecovery`; + - `aidartest-001` успешно подтягивается с `shineup.me`; + - итоговое локальное состояние по `aidartest-001` дошло до `last_block_number=13`. +- В `Dev_Docs/Blockchain/sync-between-servers.md` добавлен практический результат ручной проверки на тестовом сервере. + +## 2026-06-26 17:03:22 +0400 +- Базовый коммит-ориентир: `71fdee0`. +- Обычный `AddBlock` переведён на crash-safe схему через временный кандидат `.tmp_bch`, sidecar `.write_check` и marker `.write_pending`. +- `BlockchainTmpRecoveryOnStartup` теперь разбирает marker-driven recovery для обычной записи блока: + - если marker есть, recovery либо завершает swap tmp -> main, либо удаляет мусор; + - если marker нет, временные артефакты считаются мусором и удаляются. +- В `Dev_Docs/Blockchain/sync-between-servers.md` добавлено описание обычного `AddBlock` recovery и разделение между `write_pending` и `resync_pending`. + +## 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)`: + - обновлён перечень типов в `11_TEXT_Blocks.md`; + - обновлена быстрая карта типов в `00_Blockchain_Formats_and_Block_Types.md`. +- Уточнено API-описание поддержанных подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`. +- В документе `Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`. + +## 2026-05-20 11:34:17 +0300 +- Базовый коммит-ориентир: `a53444b`. +- В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы: + - `60/61` — `known_person / unknown_person` (знаю этого человека); + - `70/71` — `shine_confirmed / shine_unconfirmed` (точно уверен, что сияющий); + - `74/75` — `shine_seen / shine_unseen` (мало знаком, но видел сияющим). +- Обновлён список CONNECTION-подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`. + +## 2026-05-19 20:30:21 +0300 +- Базовый коммит-ориентир: `7986184`. +- Уточнён документ `11_TEXT_Blocks.md`: для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксировано, что `textLen=0` допустим и трактуется как логическое удаление сообщения. +- Явно закреплено, что отдельного `DELETE`-подтипа нет, удаление выполняется edit-блоком. + +## 2026-05-19 00:22:46 +0300 +- Базовый коммит-ориентир: `c27da63a3e65`. +- Актуализирован `README.md` как точка входа для MVP-документации по протоколу. +- В документации явно зафиксировано, что `channelType=100` и `channelType=200` присутствуют в формате, но пока не используются в UI. +- Актуализирован перечень REACTION-подтипов: добавлен `REACTION_UNLIKE (subType=2)`. +- Актуализирован перечень CONNECTION-подтипов: добавлены `SPOUSE/PARENT/CHILD/SIBLING` и обратные операции. +- В документ `02_Blockchain_Kinds_and_Lines.md` добавлены фактические серверные правила валидации line-полей. +- Обновлён корневой `AGENTS.md`: формат блокчейна менять только после явного подтверждения пользователя и с предварительным предупреждением. + +## 2026-05-13 00:02:32 +0300 +- Базовый коммит-ориентир: `f63f40f1eb2f`. +- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`. +- Зафиксированы типы каналов: `0=stories`, `1=public`, `100=personal`, `200=group`. +- Серверная уникальность имени канала изменена на `owner + type + name(slug)`. +- Root-канал `0` переименован в `stories` на уровне API-чтения. +- Для персонального канала (`type=100`) включена сборка парного потока при чтении (`A->B` + `B->A`, если существует). +- Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении. +- Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками). +- В `AGENTS.md` добавлено обязательное правило актуализации документации в `Dev_Docs/Blockchain/`. diff --git a/Dev_Docs/Blockchain/README.md b/docs/Blockchain/README.md similarity index 71% rename from Dev_Docs/Blockchain/README.md rename to docs/Blockchain/README.md index 4f3177d..302df73 100644 --- a/Dev_Docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -24,10 +24,16 @@ 10. [CHANGELOG.md](./CHANGELOG.md) Журнал изменений документации. +## Смежная документация +- [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор. +- [../ИТХ/Спецификация_ИТХ_v1.md](../ИТХ/Спецификация_ИТХ_v1.md) — точная спецификация чекпоинтов (Arweave/Solana/канал закрытий). +- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блоков и DM. + ## Важные ограничения MVP - Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI. - Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`. ## Обязательное сопровождение - При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений. +- Обычный `AddBlock` сейчас пишет через `.tmp_bch`, `.write_check` и `.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery. - Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md new file mode 100644 index 0000000..c8a4fc5 --- /dev/null +++ b/docs/Blockchain/sync-between-servers.md @@ -0,0 +1,256 @@ +# Синхронизация блоков и DM между серверами SHiNE + +Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE. + +## 1. Зачем нужна синхронизация + +Пользователи SHiNE могут быть «приписаны» к разным серверам. +Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y): + +1. Сервер X принимает сообщение; +2. Сервер X должен переслать DM-блок серверу Y; +3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B. + +Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться, +чтобы любой партнёрский сервер мог отдать полную историю пользователя. + +## 2. Список серверов синхронизации (`sync_servers`) + +Каждый сервер регистрирует в своей Solana PDA список `sync_servers` — +логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется. + +- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера. +- Адрес каждого партнёрского сервера читается из его PDA на Solana. +- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`. + +## 3. Что синхронизируется + +### 3.1 Личные сообщения (DM) + +- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt). +- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя. +- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии. +- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`. + +### 3.2 Блоки пользовательского блокчейна + +- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него. +- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`. +- Порядок блоков сохраняется (по глобальному номеру блока и хэшу). +- Дедупликация по глобальному номеру блока и хэшу. + +## 4. Текущая реализованная схема + +На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов. + +### 4.1 Что уже сделано + +1. При старте сервер читает свой `server.SHiNE.login`. +2. По этому логину он загружает из Solana свою server PDA. +3. Из неё вытаскивает список `sync_servers`. +4. Для каждого логина партнёра сервер читает его PDA и сохраняет локально: + - `login` + - `server_address` +5. После этого: + - новые локальные `AddBlock` рассылаются партнёрам в фоне; + - при старте запускается periodic sync; + - periodic sync повторяется каждые `12` часов после старта. + +### 4.2 Какие server-to-server API уже используются + +- `ListBlockchainHeads` — список heads всех локальных цепочек партнёра; +- `GetBlockchainBlock` — чтение одного конкретного блока партнёра; +- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания `solana_users + blockchain_state` без обращения в Solana RPC. + +### 4.3 Как сейчас работает periodic sync + +Для каждого сервера из локальной таблицы `sync_servers`: + +1. запрашивается `ListBlockchainHeads`; +2. для каждой удалённой цепочки сравниваются: + - `lastBlockNumber` + - `lastBlockHash` + - локальное состояние; +3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`; +4. каждый скачанный блок локально применяется через существующий `AddBlock`; +5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальный `solana_users + blockchain_state`. +6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync: + - цепочка помечается in-memory как `resync in progress`; + - создаётся marker-file в `data/`; + - в одной SQL-транзакции очищаются локальные данные цепочки и корректируются чужие счётчики; + - удаляются `.bch` и `.tmp_bch`; + - цепочка подтягивается заново с `0` через `GetBlockchainBlock`. + - обычный `AddBlock` на эту цепочку в этот момент возвращает `chain_resync_in_progress`. + +### 4.4 Как именно работает full resync + +Full resync запускается только тогда, когда: + +- локальная chain отстаёт и обычная докачка хвоста упирается в `bad_prev_hash` или `bad_block_number`; +- либо высота цепочек одинаковая, но удалённая версия сильнее по правилу: + - `lastBlockNumber`; + - `fileSizeBytes`; + - `lastBlockHash`. + +Порядок действий: + +1. Ставится in-memory guard на `blockchainName`. +2. Создаётся marker-file `.resync_pending`. +3. Обычный `AddBlock` на эту chain временно получает `chain_resync_in_progress`. +4. Вызывается атомарный SQL cleanup одной chain: + - уменьшаются чужие `likes_count` и `replies_count`; + - удаляются локальные derived-state записи этой chain; + - удаляются `blocks` и `blockchain_state` этой chain. +5. Удаляются файлы `.bch` и `.tmp_bch`. +6. Локальная chain создаётся заново через `GetSyncUserProfile` или через Solana import, если `sync.importUserProfileFromPartner.enabled=false`. +7. Chain replay-ится с `0` через `GetBlockchainBlock`. +8. Если всё прошло успешно, marker-file удаляется. +9. Если на любом шаге произошёл сбой, marker-file остаётся на диске, и сервер добивает эту chain при следующем старте. + +Важно: + +- full resync не делает умный rollback по одному блоку; +- full resync не трогает DM-таблицы и `solana_users`; +- висячие cross-chain ссылки считаются допустимым поведением системы. + +### 4.5 Как работает обычный `AddBlock` и его recovery + +Обычная запись блока теперь тоже идёт через временные артефакты: + +1. собирается `.tmp_bch` как полный кандидат на замену основного файла; +2. пишется маленький sidecar `.write_check` с `blockNumber` и `blockHash`; +3. только после этого создаётся пустой marker `.write_pending`; +4. выполняется SQL-транзакция; +5. после `commit` tmp атомарно ставится на место основного `.bch`; +6. marker и sidecar удаляются. + +На старте `BlockchainTmpRecoveryOnStartup` смотрит именно на эту пару: + +- если `write_pending` есть, recovery проверяет sidecar и БД, а затем либо завершает swap, либо чистит временные файлы; +- если `write_pending` нет, а `tmp_bch` или `write_check` остались, это мусор и он удаляется; +- `resync_pending` сюда не относится, это отдельный recovery-поток. + +### 4.6 Startup recovery по marker-file + +При старте сервер идёт в таком порядке: + +1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`; +2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`; +3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`. + +Если marker-file существует: + +- сервер не должен начинать обычную работу поверх этой chain; +- recovery снова выполняет cleanup и replay с нуля; +- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain. + +### 4.7 Зачем понадобился `GetSyncUserProfile` + +Изначально подготовка локальной цепочки делалась через Solana: + +- из `blockchainName` извлекался `login`; +- сервер вызывал import пользователя из Solana PDA; +- по данным PDA локально создавались `solana_users + blockchain_state`. + +На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`. + +Поэтому добавлен отдельный обходной режим: + +- настройка `sync.importUserProfileFromPartner.enabled=true` +- в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync; +- вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра. +- если локальный `solana_users` уже существует, sync восстанавливает только `blockchain_state` и не трогает identity-слой. + +Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint. + +### 4.8 Что делает настройка `sync.importUserProfileFromPartner.enabled` + +- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA; +- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`. + +Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync. + +## 5. Целевой протокол следующего этапа + +### 5.1 Межсерверное соединение + +- Серверы устанавливают постоянное WebSocket-соединение друг с другом. +- Адрес партнёра определяется по `server_address` из его Solana PDA. +- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA). +- При разрыве — переподключение с экспоненциальным backoff. + +### 5.2 Доставка новых данных (push) + +- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам. +- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff. + +### 5.3 Начальная синхронизация (backfill) + +- При первом подключении к партнёру серверы обмениваются «курсорами» состояния: + последний глобальный номер блока, последний известный DM-ключ. +- Сервер с более полной историей досылает недостающее партнёру. + +### 5.4 Разрешение конфликтов + +- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока. + Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`). +- DM: конфликтов нет, `message_key` уникален. + +## 6. Маршрутизация DM между серверами + +При отправке DM от пользователя A к пользователю B: + +1. Клиент A отправляет пару блоков на свой сервер X. +2. Сервер X определяет, на каком сервере зарегистрирован пользователь B. + - Сначала проверяет локально (если B зарегистрирован на X). + - Иначе читает PDA пользователя B из Solana и смотрит `access_servers`. + - Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM. +3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки. + +Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения). + +## 7. Безопасность + +- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое. +- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа). +- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере. + +## 8. Статус реализации + +| Компонент | Статус | +|-----------|--------| +| Регистрация серверной PDA в Solana | ✅ Реализовано | +| Чтение `sync_servers` из PDA | ✅ Реализовано | +| Локальная таблица `sync_servers` | ✅ Реализовано | +| Публичный `ListBlockchainHeads` | ✅ Реализовано | +| Публичный `GetBlockchainBlock` | ✅ Реализовано | +| Публичный `GetSyncUserProfile` | ✅ Реализовано | +| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано | +| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | +| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | +| Межсерверный постоянный WebSocket-канал | Нужна реализация | +| Push новых DM партнёрам | Нужна реализация | +| Push блоков блокчейна партнёрам | ✅ Реализована базовая one-shot версия | +| Periodic backfill отсутствующего хвоста | ✅ Реализовано | +| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync | +| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано | +| Маршрутизация DM через access_servers | Нужна реализация (заглушка) | + +Текущая версия сервера уже умеет базовую синхронизацию блокчейнов между партнёрами. +Не реализованы ещё DM-sync и постоянные server-to-server соединения. + +Следующие отдельные шаги после текущего этапа: +- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов. + +### 8.1 Практическая проверка на тестовом сервере + +Проверка на `t.shineup.me` показала, что текущая схема действительно поднимает цепочку при старте: + +- после рестарта сервер сначала проходит `BlockchainTmpRecovery`; +- затем обрабатывает `BlockchainResyncRecovery`; +- после этого сам догружает цепочку `aidartest-001` с `shineup.me`; +- итоговое состояние на тестовом сервере: + - `blockchain_state.last_block_number = 13` + - `blocks` по `aidartest-001` = `14` записей + +Это подтверждает, что startup sync и full-resync flow работают в живом сценарии, а не только в коде. diff --git a/docs/Figma/README.md b/docs/Figma/README.md new file mode 100644 index 0000000..fc6c083 --- /dev/null +++ b/docs/Figma/README.md @@ -0,0 +1,33 @@ +# Figma + +Эта папка хранит рабочие инструкции по переносу экранов SHiNE в Figma и по обратному переносу изменений из Figma в код. + +## Что здесь лежит + +- `README.md` — точка входа и краткий регламент. +- `TRANSFER_UI_SCREENS.md` — подробная инструкция по переносу экранов UI в Figma и обратно. + +## Когда читать + +Читать перед любыми задачами вида: +- перенести экран из `shine-UI` в Figma; +- собрать новый Figma-файл для экранов SHiNE; +- перенести изменения из Figma обратно в код; +- уточнить, каким способом переносить экраны: по одному или пачкой. + +## Ключевое правило + +Для экранов SHiNE безопасный рабочий способ на текущий момент: +- переносить экраны в Figma по одному; +- не пытаться сразу переносить длинный auth-flow пачкой; +- после каждого переноса визуально проверять результат в самой Figma; +- только после удачного одного экрана переходить к следующему. + +## Про Miro + +Отдельной папки `Miro` пока нет. + +Причина: +- практики по Miro в проекте пока мало; +- устойчивого процесса ещё нет; +- как только появится стабильный сценарий работы с Miro, его нужно будет оформить аналогично Figma. diff --git a/docs/Figma/TRANSFER_UI_SCREENS.md b/docs/Figma/TRANSFER_UI_SCREENS.md new file mode 100644 index 0000000..5e4bdb4 --- /dev/null +++ b/docs/Figma/TRANSFER_UI_SCREENS.md @@ -0,0 +1,224 @@ +# Перенос экранов UI в Figma и обратно + +## Зачем нужен этот документ + +Этот документ фиксирует практический опыт, который уже был получен на переносе стартового экрана, экрана регистрации и дальнейших попытках. + +Главная цель: +- чтобы агент не повторял неудачные попытки; +- чтобы переносы делались одинаково; +- чтобы изменения из Figma можно было уверенно переносить назад в `shine-UI`. + +## Где находится основной UI + +- основной клиентский UI: `shine-UI/` +- маршруты и список pre-auth экранов: `shine-UI/js/router.js` +- экраны: `shine-UI/js/pages/` +- общие стили: `shine-UI/styles/main.css`, `shine-UI/styles/layout.css`, `shine-UI/styles/components.css` + +## Что считать успешным переносом в Figma + +Успешный перенос экрана в Figma — это не просто фон и прямоугольники. + +Нужно, чтобы: +- были видны все ключевые текстовые элементы; +- кнопки были перенесены как отдельные элементы; +- поля ввода были явно видны; +- экран был узнаваем визуально; +- пользователь мог вручную подправить макет в Figma; +- после правок можно было понять, что именно переносить обратно в код. + +## Текущий рабочий способ + +На текущем проекте лучший практический способ такой: + +1. Переносить только один экран за раз. +2. Сначала читать конкретный `js/pages/.js`. +3. Затем читать связанные стили из `styles/components.css` и `styles/layout.css`. +4. После этого вручную собирать экран в Figma как отдельный frame с явными элементами. +5. Проверять в Figma, что не получился только фон без текста и контролов. +6. Только после успешной проверки переходить к следующему экрану. + +## Почему нельзя переносить пачкой + +Был получен негативный опыт: +- при переносе сразу многих экранов в Figma часть экранов отображалась как фон без нормальных надписей и элементов; +- длинные экраны с большим количеством текста и форм разваливались; +- автогенерация давала внешний вид, непригодный для ручной доработки. + +Поэтому правило такое: +- auth-flow, регистрация, вход, onboarding — переносить по одному экрану; +- после каждого экрана ждать визуального подтверждения пользователя; +- не объединять 5-10 экранов в один проход без отдельного разрешения и без промежуточной проверки. + +## Рекомендуемый порядок переноса в Figma + +### Вперёд: код -> Figma + +1. Определить точный экран. +2. Найти файл экрана в `shine-UI/js/pages/`. +3. Найти используемые CSS-классы через поиск по файлу экрана. +4. Вытащить: + - тексты; + - состав кнопок; + - поля ввода; + - карточки; + - блоки статуса; + - последовательность секций. +5. Если экран длинный, всё равно переносить его как один frame, но собирать блоками сверху вниз. +6. В Figma создавать отдельный экран рядом с уже существующими экранами, а не смешивать всё в одну кучу. +7. После создания экрана проверить метаданные/скриншот Figma, если инструмент это позволяет. + +### Назад: Figma -> код + +1. Снять актуальный скриншот изменённого Figma-экрана. +2. Получить метаданные узла, если это помогает понять структуру. +3. Сравнить Figma с текущим кодом экрана. +4. Переносить обратно в код только реальные изменения: + - порядок блоков; + - тексты; + - размеры/отступы; + - наличие или отсутствие карточек; + - подписи кнопок; + - видимость блоков. +5. Не придумывать новые UX-решения без отдельного подтверждения пользователя, если их нет в Figma. +6. После правок проверять экран локально или как минимум по коду и зависимостям. + +## Что переносить вручную + +Вручную, а не автогенерацией, нужно переносить: +- экраны регистрации; +- экраны входа; +- длинные формы; +- экраны с несколькими карточками; +- экраны с длинными объясняющими текстами; +- экраны, где важен порядок блоков. + +Причина: +- именно они чаще всего ломаются при слишком автоматическом переносе. + +## Какие ошибки уже были + +### Ошибка 1. Перенос пачкой + +Проблема: +- несколько экранов были добавлены сразу; +- пользователь увидел, что на экранах в Figma «какая-то ерунда». + +Вывод: +- переносить по одному. + +### Ошибка 2. Видно только фон + +Проблема: +- frame создавался, фон и свечения были видны; +- тексты и элементы либо не появлялись, либо получались непригодными. + +Вывод: +- при сложных экранах собирать элементы вручную и явно. + +### Ошибка 3. Слишком вольная реконструкция + +Проблема: +- экран формально был перенесён, но визуально не соответствовал ожиданию пользователя. + +Вывод: +- для SHiNE важнее узнаваемый и редактируемый экран, чем «формально похожий» экран. + +## Обязательные проверки после переноса в Figma + +После каждого нового экрана агент должен проверить: +- виден ли заголовок; +- видны ли кнопки; +- видны ли поля ввода; +- не исчезли ли длинные тексты; +- не сломан ли порядок секций; +- не оказался ли на холсте только фон и пустые прямоугольники. + +Если хотя бы один пункт не выполнен: +- не считать перенос завершённым; +- либо переделать экран сразу; +- либо остановиться и показать пользователю только после исправления. + +## Правила для длинных экранов + +Если экран длинный, например регистрация: +- высота frame может быть больше стандартной мобильной высоты; +- секции должны идти в правильном вертикальном порядке; +- отдельные карточки должны быть вынесены в отдельные блоки; +- тексты лучше упрощённо располагать вручную, чем терять их совсем. + +## Правила для экрана регистрации + +Экран `register-view` особенно чувствительный. + +При переносе нужно отдельно учитывать: +- заголовок и стрелку назад; +- поля логина и пароля; +- переключатель режима 12 слов; +- сетку слов; +- строку статуса длины пароля; +- строку статуса проверки логина; +- кнопку проверки логина; +- отдельную карточку первого сервера; +- отдельную карточку FAQ; +- нижние кнопки `Назад` и `Далее`. + +## Правила для экрана входа + +Для экранов входа важно не смешивать: +- экран выбора способа входа; +- вход по логину/паролю; +- вход через другое устройство; +- вход по QR. + +Каждый из них переносить отдельно. + +## Что делать после правок пользователя в Figma + +Если пользователь изменил экран в Figma: + +1. Считать Figma источником визуальной правки. +2. Сначала понять, что именно изменено: + - тексты; + - порядок блоков; + - наличие блоков; + - размеры; + - отступы; + - логика flow. +3. Переносить эти изменения назад в код минимально необходимыми правками. +4. Если из Figma следует уже не только визуальная, но и UX-логическая правка, отдельно проверить, что она согласована пользователем. + +## Когда нужно добавить заметку в Pending_Features + +Если после изменения по Figma: +- поменялась логика flow; +- поменялась регистрация/вход; +- нужен реальный прогон на test2; +- затронута интеграция с Solana; + +тогда нужно добавить файл в `Dev_Docs/Pending_Features/`. + +## Что пока не оформлено для Miro + +По Miro пока нет устойчивого процесса. + +Из того, что уже понятно: +- пока не стоит обещать такой же отлаженный перенос, как для Figma; +- сначала нужно накопить хотя бы 2-3 реальных сценария работы; +- только после этого оформлять отдельную папку и регламент. + +## Краткая памятка для агента + +Если задача звучит как: +- «перенеси экран в Figma»; +- «добавь экран в Figma»; +- «я поправил экран в Figma, перенеси назад»; + +то агент должен: + +1. Прочитать этот документ. +2. Работать по одному экрану. +3. Не переносить auth-flow пачкой. +4. Проверять результат после каждого экрана. +5. При переносе обратно в код не гадать, а опираться на Figma-правки. diff --git a/Dev_Docs/Keys/DERIVATION.md b/docs/Keys/DERIVATION.md similarity index 100% rename from Dev_Docs/Keys/DERIVATION.md rename to docs/Keys/DERIVATION.md diff --git a/Dev_Docs/Keys/README.md b/docs/Keys/README.md similarity index 97% rename from Dev_Docs/Keys/README.md rename to docs/Keys/README.md index 4afb114..6178525 100644 --- a/Dev_Docs/Keys/README.md +++ b/docs/Keys/README.md @@ -159,7 +159,8 @@ Self-message - это сообщение пользователя самому ## Связанные документы - `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код). -- `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений. +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений. +- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений. - `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. - `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. - `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации. diff --git a/docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md b/docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md new file mode 100644 index 0000000..bbe28be --- /dev/null +++ b/docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md @@ -0,0 +1,38 @@ +# Поддержать проект Сияние + +Статус: `pending` + +## Кратко + +В `shine-UI/js/pages/wallet-view.js` добавлен новый раздел `Поддержать проект Сияние` с тремя входами: + +1. купить билет; +2. посмотреть билет по номеру; +3. сгенерировать новую пару ключей. + +## Что проверить + +1. Открыть `Кошелёк`. +2. Перейти в `Поддержать проект Сияние`. +3. Проверить экран покупки: + - виден коэффициент; + - виден остаток лимита очереди 1; + - виден расчет в SOL; + - кнопка `Справка` открывает отдельный экран; + - покупка блокируется, если сумма больше остатка лимита. +4. Проверить экран просмотра: + - `12` ищется как билет очереди 1; + - `2-5` и `3 8` ищутся как билеты очередей 2 и 3; + - показываются статус, количество билетов до него и уже выплаченные значения. +5. Проверить генератор ключей: + - генерируется новая пара ключей; + - публичный и секретный ключи показываются; + - можно скопировать и скачать результат; + - дополнительный текст в поле необязателен. + +## Ожидаемый результат + +- Экран раздела поддержки открывается из `wallet-view`. +- Покупка билета выполняется по текущему курсу и с допуском 3%. +- По номеру билета показывается понятная сводка по очереди. +- Генерация ключей использует безопасный браузерный рандом и не требует сохранения секретного ключа. diff --git a/docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md b/docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md new file mode 100644 index 0000000..b60b090 --- /dev/null +++ b/docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md @@ -0,0 +1,20 @@ +# Promo-логины через продавцов в Solana + +- краткое описание: + - в `shine_users` добавлена поддержка PDA продавцов красивых логинов, promo-подписи `shine_promo_v1:` и отдельная автономная страница `shine-UI/promo-code-generator.html` для генерации promo-кода через браузерный кошелёк или через ручной ввод Base58 seed 32 bytes; +- что проверять: + - admin-транзакцией создать или обновить `promo_seller_pda`; + - сгенерировать promo-код на странице `promo-code-generator.html` в режиме wallet extension; + - сгенерировать promo-код на той же странице в режиме ручного Base58 seed; + - открыть HTML локально как отдельный файл без сервера и убедиться, что генерация в режиме Base58 seed продолжает работать; + - зарегистрировать короткий/premium логин по promo-коду; + - убедиться, что без promo-кода тот же логин не проходит `shine_login_guard`; + - убедиться, что после успешной регистрации `remaining_sales` уменьшается на 1; + - проверить отказ при исчерпанной квоте, неверной подписи и логине короче `min_login_length`; +- ожидаемый результат: + - promo-регистрация проходит только при валидной подписи продавца и соблюдении лимитов; + - обычная регистрация без promo-кода продолжает работать как раньше; + - страница генерации выдаёт строку формата `1seller-signatureBase58` в обоих режимах; + - single-file HTML работает локально оффлайн минимум в режиме ручного Base58 seed; +- статус: + - `pending` diff --git a/docs/Pending_Features/2026-07-01_1945_mainnet-solana-addresses-sync.md b/docs/Pending_Features/2026-07-01_1945_mainnet-solana-addresses-sync.md new file mode 100644 index 0000000..d309652 --- /dev/null +++ b/docs/Pending_Features/2026-07-01_1945_mainnet-solana-addresses-sync.md @@ -0,0 +1,51 @@ +# Синхронизация mainnet-адресов Solana по UI/серверу/ESP32 + +- статус: `pending` + +## Кратко + +Во все основные клиентские и серверные точки проекта протянуты актуальные mainnet-адреса программ: + +- `shine_login_guard`: `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` +- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` +- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` + +Также по умолчанию переключены основные RPC defaults на `https://api.mainnet-beta.solana.com` там, где это нужно для рабочего mainnet-сценария. + +## Что проверять + +1. Основной UI: + - отображение и использование новых program id; + - регистрация/чтение Solana PDA в mainnet; + - экран кошелька и покупка лимита. + +2. Server UI: + - создание server PDA в mainnet; + - чтение PDA; + - обновление PDA; + - корректная блокировка devnet-автопополнения при mainnet endpoint. + +3. Browser plugin wallet: + - резолв `user_pda` и `server PDA` через mainnet RPC. + +4. Web UI `shine_payments`: + - чтение очередей; + - покупка билета; + - admin/manager/dao tools открываются с новыми program id и mainnet RPC. + +5. ESP32 homeserver UI: + - отображаются новые program id; + - дефолтный Solana RPC теперь mainnet. + +6. Временный режим регистрации: + - в основном UI логины длиной 5-7 символов без временного кода не проходят; + - в основном UI логины длиной 5-7 символов с временным кодом проходят локальную проверку; + - в основном UI любой другой временный код отклоняется сразу; + - ESP32 и прямой on-chain вызов `shine_users` полагаются на текущий временный `shine_login_guard`, то есть валидные логины длиной от 5 символов допускаются без словарной классификации. + +## Ожидаемый результат + +- Во всех перечисленных поверхностях используются новые mainnet program id. +- Запросы по умолчанию идут в mainnet, кроме явно devnet-утилит. +- Нет обращений к старым devnet program id. +- Временный UI-барьер для коротких логинов работает так, как задумано, и не ломает обычную регистрацию для логинов от 8 символов. diff --git a/docs/Pending_Features/2026-07-01_2135_временный_режим_коротких_логинов.md b/docs/Pending_Features/2026-07-01_2135_временный_режим_коротких_логинов.md new file mode 100644 index 0000000..71e5a52 --- /dev/null +++ b/docs/Pending_Features/2026-07-01_2135_временный_режим_коротких_логинов.md @@ -0,0 +1,42 @@ +# Временный режим коротких логинов + +- статус: `pending` + +## Кратко + +Временно упрощён `shine_login_guard`: + +- on-chain допускаются любые валидные логины длиной от `5` символов; +- словарная premium/trademark-классификация временно отключена. + +Поверх этого основной UI вводит временное ограничение: + +- логины длиной `8+` проходят обычную проверку; +- логины длиной `5..7` допускаются только при вводе временного кода; +- любой другой код отклоняется сразу. + +ESP32 в этом временном режиме не ограничивается UI-кодом и использует только on-chain правила. + +## Что проверять + +1. Основной UI: + - логин длиной `4` символа отклоняется; + - логин длиной `5..7` без временного кода отклоняется; + - логин длиной `5..7` с корректным временным кодом проходит локальную проверку; + - логин длиной `5..7` с любым другим кодом отклоняется; + - логин длиной `8+` проходит без временного кода. + +2. Solana on-chain: + - `shine_login_guard` возвращает `CLASS_FREE` для валидных логинов длиной от `5`; + - `shine_login_guard` возвращает `CLASS_PREMIUM` для логинов короче `5` или с невалидными символами. + +3. ESP32 / прямой вызов: + - валидный логин длиной `5+` регистрируется без временного кода; + - логин короче `5` не регистрируется. + +## Ожидаемый результат + +- Основной UI удерживает обычных пользователей от коротких логинов без временного кода. +- Свои пользователи могут временно регистрировать короткие логины через UI-код. +- ESP32 продолжает работать без отдельной промо-логики. +- On-chain логика остаётся простой и компактной. diff --git a/docs/Pending_Features/2026-07-09_0815_4_test_servers_devnet_vps.md b/docs/Pending_Features/2026-07-09_0815_4_test_servers_devnet_vps.md new file mode 100644 index 0000000..917c2b8 --- /dev/null +++ b/docs/Pending_Features/2026-07-09_0815_4_test_servers_devnet_vps.md @@ -0,0 +1,39 @@ +# 4 тестовых SHiNE-сервера на одном VPS (`178.208.90.249`) + +- статус: `pending` + +## Кратко + +Поднят отдельный тестовый стенд: +- `t1.shineup.me` +- `t2.shineup.me` +- `t3.shineup.me` +- `t4.shineup.me` + +Каждый домен ведёт на свой SHiNE-инстанс под пользователем `player`, все инстансы работают через Solana `devnet`. + +## Что проверять + +- UI каждого домена открывается без ошибок: + - `https://t1.shineup.me` + - `https://t2.shineup.me` + - `https://t3.shineup.me` + - `https://t4.shineup.me` +- регистрация/логин клиента может работать через каждый из этих серверов +- WebSocket-подключение реально идёт на свой инстанс, а не в соседний +- чтение PDA и server-ui формы по умолчанию используют `devnet` +- межсерверный sync после ручных тестов действительно использует `access_servers/sync_servers` +- не происходит путаницы данных между `t1/t2/t3/t4` + +## Ожидаемый результат + +- каждый домен работает независимо +- у каждого инстанса своя БД и свои локальные данные +- `Caddy` корректно проксирует `/ws` на `7101..7104` +- серверы не падают после старта +- если devnet RPC временно даёт `429`, последовательный рестарт по одному инстансу позволяет подтянуть `sync_servers` + +## Примечание + +Подробная схема размещения описана в: +- [Dev_Docs/deploy/test-server/178.208.90.249_quad_devnet.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Dev_Docs/deploy/test-server/178.208.90.249_quad_devnet.md) diff --git a/docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md b/docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md new file mode 100644 index 0000000..2db1f74 --- /dev/null +++ b/docs/Pending_Features/2026-07-10_1248_call_summary_dm_from_initiator.md @@ -0,0 +1,17 @@ +# Итог звонка как DM от инициатора + +- краткое описание фичи: + Вместо локальных `call-tech` записей итог звонка теперь отправляется как обычное DM-сообщение только от стороны, которая инициировала звонок. + +- что именно проверять: + 1. Успешный звонок: после завершения инициатор отправляет в чат DM вида `Звонок: 37с` или `Звонок: 2м 14с`. + 2. Неуспешный звонок без ответа: инициатор отправляет `Звонил, но недозвонился: нет ответа`. + 3. Неуспешный звонок, когда у адресата нет доставленных сессий: инициатор отправляет `Звонил, но недозвонился: абонент не в сети`. + 4. Неуспешный звонок при проблеме соединения: инициатор отправляет `Звонил, но недозвонился: не удалось установить соединение`. + 5. Старые локальные `call-tech` bubble про итог звонка больше не появляются ни у инициатора, ни у принимающей стороны. + +- ожидаемый результат: + Итог звонка виден обеим сторонам как обычное DM-сообщение от инициатора, а локальные служебные записи про итог звонка больше не используются. + +- статус: + pending diff --git a/docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md b/docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md new file mode 100644 index 0000000..173118a --- /dev/null +++ b/docs/Pending_Features/2026-07-10_1748_dm_opaque_body_and_decrypt_fallback.md @@ -0,0 +1,23 @@ +## Краткое описание + +Сервер перестал валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`. +UI теперь не отбрасывает такие сообщения: если сообщение не удалось расшифровать, вместо текста показывается заглушка `Неудалось расшифровать сообщение`. + +## Что проверять + +- Отправка и приём обычных DM между штатными клиентами продолжают работать как раньше. +- Сообщение с незнакомым форматом `body` у `type=1/2` принимается сервером и доходит до клиента. +- В UI такое сообщение отображается в чате одной из ожидаемых заглушек: + - `Формат сообщения не поддерживается` + - `Не удалось расшифровать сообщение` + - `Сообщение повреждено` +- После выхода из аккаунта и повторного входа backlog с такими сообщениями тоже отображается с той же заглушкой. +- ACK доставки по сессии для такого сообщения не зацикливается и сообщение не приходит бесконечно повторно. + +## Ожидаемый результат + +Сервер выступает транспортом для opaque DM-body, а клиент при неудачной расшифровке показывает безопасный fallback вместо полного пропуска сообщения с различением основных причин. + +## Статус + +pending diff --git a/docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md b/docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md new file mode 100644 index 0000000..98d897c --- /dev/null +++ b/docs/Pending_Features/2026-07-10_1845_dm_shine_tags_and_safe_preview.md @@ -0,0 +1,28 @@ +## Краткое описание + +В DM добавлен клиентский формат технических вставок в начале plaintext: + +- `` +- `` + +UI скрывает такие вставки из текста сообщения, а call-вставки отображает специальным человекочитаемым видом. +Также добавлена защита от пользовательского текста, начинающегося с `` показывается в чате как специальное call-сообщение, а не как сырой техтекст. +- При исходящем звонке короче `5` секунд call-summary не отправляется вообще. +- В списке личных сообщений превью такого сообщения показывает человекочитаемый итог звонка. +- DM с `Текст ответа` скрывает техблок и показывает только `Текст ответа`. +- В меню любого DM первым пунктом есть `Ответить`, и отправка из этого режима реально добавляет `` в начало plaintext. +- Если reply-цель отсутствует, сообщение всё равно показывается как обычный текст ответа. +- Текст вида `` или похожие HTML-теги не исполняются ни в чате, ни в списке личных сообщений, ни в списке каналов. + +## Ожидаемый результат + +Технические SHiNE-вставки работают только как управляющие метаданные UI, а отображаемый пользователю текст и превью остаются безопасными и не рендерят HTML. + +## Статус + +pending diff --git a/docs/Pending_Features/2026-07-14_1325_fix_call_remote_hangup_and_ack_timeout.md b/docs/Pending_Features/2026-07-14_1325_fix_call_remote_hangup_and_ack_timeout.md new file mode 100644 index 0000000..d81fe49 --- /dev/null +++ b/docs/Pending_Features/2026-07-14_1325_fix_call_remote_hangup_and_ack_timeout.md @@ -0,0 +1,30 @@ +# Исправление завершения звонка и таймаутов ACK + +## Краткое описание + +Исправлена клиентская логика звонков: +- входящий экран теперь должен закрываться сразу при `remote_hangup`; +- после нажатия `Ответить` добавлен таймаут ожидания стадии `connecting`, чтобы окно не висело бесконечно; +- исходящий таймаут ожидания ACK больше не обрывает звонок слишком рано, если invite уже был доставлен. +- в локальный app log клиента добавлен точный timeline по этапам звонка: invite, выбор remoteSessionId, accept/offer/answer, ICE, смены `RTCPeerConnection` и финализация. +- добавлена явная диагностика `SESSION_NOT_FOUND` для звонковых сигналов: отдельный timeline-этап в клиенте и отдельный серверный warn-лог. + +## Что проверять + +- Если исходящая сторона завершила звонок, входящее окно у второй стороны закрывается сразу. +- Если принять входящий звонок, а соединение/offer не приходит, экран не висит бесконечно и сам завершается по таймауту. +- Если invite был доставлен, исходящий звонок не падает через 10 секунд раньше времени только из-за медленного ACK. +- В app log по звонку видна последовательность этапов с локальными `+N ms`, по которой можно понять, где именно образуется задержка или гонка. +- Если сигнал звонка не дошёл до целевой сессии, это явно видно и в клиенте, и в серверном логе. + +## Ожидаемый результат + +- Нет бесконечно висящего окна `Соединяем…` на принимающей стороне. +- В логах вместо преждевременного `no_ack_10s` для уже доставленного invite появляется более поздний таймаут. +- При `remote_hangup` звонок визуально завершается сразу. +- По каждому проблемному звонку есть локальный timeline и тот же краткий timeline попадает в `CallDeliveryReport`. +- В серверных логах есть строка `CALL_SIGNAL_SESSION_NOT_FOUND ...` с `callId`, `targetSessionId` и количеством активных сессий адресата. + +## Статус + +`pending` diff --git a/Dev_Docs/Pending_Features/2026-07-14_1745_адаптация-шапки-связей.md b/docs/Pending_Features/2026-07-14_1745_адаптация-шапки-связей.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-07-14_1745_адаптация-шапки-связей.md rename to docs/Pending_Features/2026-07-14_1745_адаптация-шапки-связей.md diff --git a/Dev_Docs/Pending_Features/2026-07-14_1830_кнопка-поиска-каналов.md b/docs/Pending_Features/2026-07-14_1830_кнопка-поиска-каналов.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-07-14_1830_кнопка-поиска-каналов.md rename to docs/Pending_Features/2026-07-14_1830_кнопка-поиска-каналов.md diff --git a/Dev_Docs/Pending_Features/2026-07-14_1915_локальный-тестовый-вход.md b/docs/Pending_Features/2026-07-14_1915_локальный-тестовый-вход.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-07-14_1915_локальный-тестовый-вход.md rename to docs/Pending_Features/2026-07-14_1915_локальный-тестовый-вход.md diff --git a/docs/Pending_Features/2026-07-15_2115_регистрация_глаз_логин_12_слов.md b/docs/Pending_Features/2026-07-15_2115_регистрация_глаз_логин_12_слов.md new file mode 100644 index 0000000..ed586be --- /dev/null +++ b/docs/Pending_Features/2026-07-15_2115_регистрация_глаз_логин_12_слов.md @@ -0,0 +1,15 @@ +# Регистрация: глаз у пароля, автопроверка логина и сохранение связки с 12 словами + +- краткое описание: + На экране регистрации добавлены кнопка-глаз у поля пароля, автопроверка логина с задержкой около 1.5 секунды и сохранение связки между обычным полем пароля и режимом `12 слов`, пока пользователь вручную не изменит пароль. +- что проверять: + Открыть экран регистрации и убедиться, что справа от поля пароля есть квадратная кнопка-глаз, которая переключает скрытый и открытый показ пароля. + Включить режим `Представить пароль в виде 12 слов`, заполнить несколько или все слова, затем выключить режим и проверить, что в поле пароля появился склеенный пароль. + Снова включить режим `12 слов` без ручного редактирования пароля и проверить, что ранее введённые слова восстановились. + После этого вручную изменить пароль в обычном поле и снова включить режим `12 слов` — набор слов должен быть очищен. + Ввести логин и перестать печатать примерно на 1.5 секунды — под полем логина должен автоматически появиться актуальный статус доступности. + Проверить, что кнопка `Проверить логин` находится ниже статуса, а текст про частые вопросы снизу страницы отсутствует. +- ожидаемый результат: + Регистрация ведёт себя предсказуемо: пароль можно показать или скрыть, слова не теряются при простом скрытии режима `12 слов`, а статус логина обновляется автоматически и вручную. +- статус: + pending diff --git a/Dev_Docs/Pending_Features/README.md b/docs/Pending_Features/README.md similarity index 100% rename from Dev_Docs/Pending_Features/README.md rename to docs/Pending_Features/README.md diff --git a/Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md b/docs/Pending_Features/вроде сделанное/2026-06-20_1839_registration_faq_and_12_words.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-06-20_1839_registration_faq_and_12_words.md rename to docs/Pending_Features/вроде сделанное/2026-06-20_1839_registration_faq_and_12_words.md diff --git a/Dev_Docs/Pending_Features/2026-06-20_2350_test_free_avatar_upload.md b/docs/Pending_Features/вроде сделанное/2026-06-20_2350_test_free_avatar_upload.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-06-20_2350_test_free_avatar_upload.md rename to docs/Pending_Features/вроде сделанное/2026-06-20_2350_test_free_avatar_upload.md diff --git a/Dev_Docs/Pending_Features/2026-06-24_1825_unified_channels_list_without_stories.md b/docs/Pending_Features/вроде сделанное/2026-06-24_1825_unified_channels_list_without_stories.md similarity index 100% rename from Dev_Docs/Pending_Features/2026-06-24_1825_unified_channels_list_without_stories.md rename to docs/Pending_Features/вроде сделанное/2026-06-24_1825_unified_channels_list_without_stories.md diff --git a/docs/Pending_Features/вроде сделанное/2026-06-26_1500_addblock_tmp_bch_recovery.md b/docs/Pending_Features/вроде сделанное/2026-06-26_1500_addblock_tmp_bch_recovery.md new file mode 100644 index 0000000..de2f63e --- /dev/null +++ b/docs/Pending_Features/вроде сделанное/2026-06-26_1500_addblock_tmp_bch_recovery.md @@ -0,0 +1,47 @@ +# Crash-safe запись обычного `AddBlock` через `tmp_bch` + +## Кратко + +Обычный `AddBlock` переведён на схему: + +1. сборка `.tmp_bch`; +2. запись sidecar `.write_check` с `blockNumber` и `blockHash`; +3. создание пустого marker `.write_pending`; +4. SQL-транзакция; +5. атомарная подмена `tmp -> main`; +6. удаление временных файлов. + +## Что проверить + +1. Обычный `AddBlock` на свежей цепочке. +2. Падение до SQL-commit: + - должны остаться только временные файлы; + - на старте они должны быть удалены. +3. Падение после SQL-commit, но до `atomicReplaceBlockchainFile(...)`: + - на старте recovery должен довести swap до конца. +4. Падение после `atomicReplaceBlockchainFile(...)`, но до удаления marker/sidecar: + - на старте recovery должен просто подчистить хвост. +5. Сценарий без marker: + - `tmp_bch` / `write_check` считаются мусором и удаляются. + +## Ожидаемый результат + +- БД и файловая версия цепочки остаются согласованными. +- Повторный старт сервера не ломает chain и не требует ручной правки файлов. +- `BlockchainTmpRecoveryOnStartup` корректно обрабатывает и живые остатки, и мусор. + +## Статус + +`pending` + +## Что уже сделано + +- В коде есть `tmp_bch`, `write_check` и `write_pending`. +- `BlockchainWriter` пишет обычный `AddBlock` через временные артефакты. +- `BlockchainTmpRecoveryOnStartup` умеет добивать или чистить незавершённую запись. + +## Что ещё перепроверить + +- ручной crash-test на тестовом сервере; +- совместимость с уже существующими `resync_pending` marker-файлами; +- отсутствие ложных срабатываний на старых временных файлах. diff --git a/docs/Pending_Features/вроде сделанное/2026-06-26_1745_proverka_avariynykh_ostanovok.md b/docs/Pending_Features/вроде сделанное/2026-06-26_1745_proverka_avariynykh_ostanovok.md new file mode 100644 index 0000000..d6dbab0 --- /dev/null +++ b/docs/Pending_Features/вроде сделанное/2026-06-26_1745_proverka_avariynykh_ostanovok.md @@ -0,0 +1,29 @@ +# Проверка аварийных остановок на разных этапах + +## Кратко + +Нужно отдельно проверить, как сервер восстанавливается после внезапной остановки: + +1. во время обычного `AddBlock` / `tmp_bch`-pipeline; +2. во время `full resync` цепочки; +3. во время startup recovery, если остановка произошла на предыдущем запуске; +4. при обычном апгрейде сервиса без явного crash-сценария. + +## Что проверять + +1. Остановка сервиса до `commit` БД. +2. Остановка сервиса после `commit`, но до замены `main.bch`. +3. Остановка сервиса во время `BlockchainResyncCleanupDAO`. +4. Остановка сервиса во время повторной загрузки цепочки по `GetBlockchainBlock`. +5. Поведение при обычном `systemctl restart`, когда сервер сам должен добить recovery. + +## Ожидаемый результат + +- после старта сервер либо дочищает временные артефакты, либо завершает незаконченный `resync`; +- не остаётся битых `.tmp_bch`, `.write_check`, `.write_pending`, `.resync_pending`; +- БД и файлы цепочки остаются согласованными; +- обычная работа сервера не стартует поверх незавершённого recovery. + +## Статус + +`pending` diff --git a/Dev_Docs/Personal_Messages/AGENTS.md b/docs/Personal_Messages/AGENTS.md similarity index 100% rename from Dev_Docs/Personal_Messages/AGENTS.md rename to docs/Personal_Messages/AGENTS.md diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md new file mode 100644 index 0000000..587d94f --- /dev/null +++ b/docs/Personal_Messages/README.md @@ -0,0 +1,19 @@ +# Личные сообщения SHiNE + +Эта папка содержит актуальную документацию по личным сообщениям SHiNE. + +Точка входа: + +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers` +- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM` +- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки + +Исторический устаревший документ сохранён отдельно: + +- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` + +Правило сопровождения: + +- код DM и оба документа `Протокол_DM_v1.md` + `Формат_DM_v1.md` всегда должны обновляться синхронно; +- если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация. +- локальная таблица пользователей для DM считается кэшем, а источником истины остаётся Solana PDA; если серверная логика DM меняет правила lazy-import пользователей из PDA, это тоже обязательно фиксируется в документации. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md new file mode 100644 index 0000000..5466c95 --- /dev/null +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -0,0 +1,581 @@ +# Личные сообщения (DM) — спецификация v1 + +## Статус документа + +Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1. + +Важно: + +- это актуальная реализованная спецификация; +- код и документы по DM должны изменяться синхронно; +- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата. + +Документ фиксирует: + +- модель DM и смысл типов сообщений; +- правила редактирования, перешифровки и удаления; +- роли существующих API-методов; +- правила межсерверной маршрутизации через `access_servers`; +- общее поведение сервера и БД. + +Точный байтовый формат контейнера вынесен отдельно: + +- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` + +Формат клиентских технических вставок внутри plaintext вынесен отдельно: + +- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md` + +Устаревшая предыдущая версия сохранена отдельно: + +- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md` + +## 1. Основная модель + +Личное сообщение в SHiNE хранится как две логические копии одного сообщения: + +- `type=1` — входящее сообщение для получателя; +- `type=2` — исходящая копия сообщения для отправителя. + +Обе копии имеют общий логический идентификатор: + +- `baseKey = fromLogin|toLogin|timeMs|nonce` + +Идентификатор конкретной копии: + +- `messageKey = baseKey|messageType` + +Поля `timeMs` и `nonce` не меняются никогда. + +`nonce` обязателен, потому что одного `timeMs` недостаточно для гарантированной уникальности. + +## 2. Ключи и шифрование + +### 2.1. Общий принцип + +У пользователя в PDA публикуется один публичный `clientKey` в формате `Ed25519`. + +Из того же ключевого материала стандартным способом выводится ключ `X25519` для E2EE-шифрования DM. + +Отдельный публичный `dmEncKey` в текущей архитектуре не хранится. + +Подробности стандартного преобразования: + +- `Dev_Docs/Протоколы/Преобразование_ED25519_в_X25519.md` + +### 2.2. Правило шифрования копий + +- `type=1` шифруется на ключ получателя; +- `type=2` шифруется на ключ отправителя. + +Следствия: + +- входящую копию может прочитать только получатель; +- исходящую копию может прочитать только отправитель; +- сервер не должен расшифровывать DM; +- ciphertext у `type=1` и `type=2` обычно разный и не обязан совпадать побайтно. + +### 2.3. Что именно знает сервер о `body` + +Для контентных DM (`type=1/2`) сервер в v1 должен рассматривать `body` как opaque blob: + +- сервер проверяет внешний контейнер `SHiNE_DM`; +- сервер проверяет подпись; +- сервер проверяет служебные поля envelope; +- сервер проверяет только то, что `bodyLen > 0` и не превышает допустимый лимит; +- сервер не обязан разбирать внутренний crypto-контейнер `body`; +- сервер не обязан знать конкретный алгоритм шифрования `body`. + +Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри `body`. + +Официальный UI SHiNE при приёме такого сообщения: + +- пытается расшифровать знакомый формат `EncryptedBody_v1_0`; +- показывает текст сообщения при успешной расшифровке; +- если внутренний формат не поддерживается, показывает `Формат сообщения не поддерживается`; +- если формат понятен, но расшифровка не удалась, показывает `Не удалось расшифровать сообщение`; +- если `body` повреждён или структурно битый, показывает `Сообщение повреждено`. + +После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `` в начале текста. +Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope. + +### 2.4. Источник истины по пользователю + +Для DM-проверки сервер использует: + +- локальную `solana_users` как кэш; +- Solana PDA как источник истины по `clientKey` и `access_servers`. + +Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA: + +- в `GetUser`; +- при серверной верификации DM-подписи; +- перед межсерверной маршрутизацией через `access_servers`. + +## 3. Типы сообщений + +В версии v1 используются следующие `messageType`: + +- `1` — входящее сообщение; +- `2` — исходящая копия сообщения; +- `3` — входящее подтверждение прочтения; +- `4` — исходящая копия подтверждения прочтения; +- `5` — сообщение удалено отправителем; +- `6` — сообщение удалено получателем; +- `7` — переписка удалена отправителем; +- `8` — переписка удалена получателем. + +Типы `1/2` — это обычные контентные DM, в том числе их последующие ревизии. + +Типы `3/4` — служебные подтверждения прочтения. + +Типы `5/6/7/8` — служебные события удаления без зашифрованного тела. + +## 4. Базовые операции + +### 4.1. Создание нового сообщения + +Новое сообщение отправляется парой блоков через: + +- `SendMessagePair` +- `ReceiveOutcomingMessage` как алиас + +Пару создаёт автор сообщения. + +Пара содержит: + +- одну входящую копию `type=1`; +- одну исходящую копию `type=2`; +- одинаковые `fromLogin`, `toLogin`, `timeMs`, `nonce`; +- одинаковый логический plaintext; +- разные ciphertext для разных владельцев копий. + +### 4.2. Редактирование сообщения + +Редактирование общего текста делает автор сообщения. + +При редактировании: + +- `baseKey` остаётся тем же; +- `messageType` остаётся тем же; +- `revisionTimeMs` увеличивается; +- автор заново шифрует обе копии и снова отправляет пару через `SendMessagePair`. + +Если `revisionTimeMs = 0`, это исходная версия. + +Если `revisionTimeMs > 0`, это новая ревизия сообщения. + +### 4.3. Перешифровка + +Перешифровка нужна для будущего сценария полной или частичной перепаковки истории сообщений при сохранении тех же логических идентификаторов сообщений. + +При перешифровке: + +- `baseKey` остаётся прежним; +- `messageKey` остаётся прежним; +- plaintext может остаться тем же; +- ciphertext меняется; +- `reencryptedAtMs` получает ненулевое значение; +- `revisionTimeMs` может остаться прежним, в том числе нулевым, если менялось только шифрование без редактирования текста; +- сервер при выборе актуальной версии обязан учитывать обе метки времени. + +Если сообщение уже удалено одним из типов `5/6`, его перешифровывать больше нельзя. + +Если переписка уже удалена типом `7/8`, более старые сообщения этой пары тоже перешифровывать нельзя. + +### 4.4. Приём входящей копии + +Для server-to-server доставки одной входящей копии используется: + +- `ReceiveIncomingMessage` + +Этот метод должен принимать: + +- новые входящие сообщения; +- входящие обновления/редактирования; +- будущие входящие перешифрованные копии. + +## 5. Удаление одного сообщения + +### 5.1. Общая логика + +Удаление одного сообщения в v1 всегда глобальное у обеих сторон. + +Локального удаления только у себя в этой версии протокола не вводится. + +### 5.2. Типы удаления + +- `type=5` — сообщение удалено отправителем; +- `type=6` — сообщение удалено получателем. + +Удаляющее сообщение: + +- не содержит зашифрованного тела; +- хранится в БД как tombstone; +- терминально закрывает это сообщение; +- не даёт больше принять никакую более позднюю содержательную версию этого же `messageKey`. + +### 5.3. Метод удаления + +Для удаления одного сообщения нужен отдельный метод, условно: + +- `DeleteMessage` + +Если сервер впервые получает валидное удаляющее сообщение: + +- сохраняет tombstone в БД; +- удаляет или замещает прежнюю версию сообщения tombstone-записью; +- распространяет это же удаление на серверы доступа обеих сторон; +- не принимает в будущем попытки "оживить" это сообщение. + +## 6. Удаление всей переписки + +### 6.1. Отдельное служебное сообщение + +Удаление всей переписки между двумя пользователями — это отдельное служебное сообщение без ciphertext. + +Типы: + +- `type=7` — переписка удалена отправителем; +- `type=8` — переписка удалена получателем. + +### 6.2. Граница удаления + +Границей удаления считается: + +- `timeMs` самого служебного сообщения удаления переписки + +Отдельное `deleteBeforeTimeMs` в этой версии не вводится. + +### 6.3. Правило применения + +Если сервер получает такое сообщение впервые: + +- сохраняет его в БД как tombstone переписки; +- удаляет из БД все сообщения этой пары пользователей с `timeMs` меньше времени служебного сообщения; +- больше не принимает новые или повторно доставленные сообщения с `timeMs` раньше этой границы; +- распространяет это же сообщение удаления переписки на серверы доступа обеих сторон. + +### 6.4. Поведение при позднем старом сообщении + +Если после удаления переписки приходит старое сообщение, у которого: + +- `timeMs < deleteConversationMessage.timeMs` + +то сервер: + +- не принимает это сообщение; +- распространяет уже известный tombstone удаления переписки на серверы доступа обеих сторон; +- ожидает, что вторая сторона обработает его как обычное уже известное удаление переписки. + +### 6.5. Будущие новые сообщения + +После удаления всей переписки новые сообщения между этими же пользователями разрешены, если: + +- их `timeMs` больше времени служебного сообщения удаления переписки. + +## 7. Серверы и маршрутизация + +### 7.1. `access_servers` + +Для обычного пользователя список серверов доставки и доступа задаётся через: + +- `access_servers` + +Это: + +- сервера доступа пользователя; +- сервера relay; +- сервера, через которые пользователь получает личные сообщения и другие пользовательские операции. + +### 7.2. `sync_servers` + +`sync_servers` относятся не к обычной пользовательской маршрутизации DM, а к server-to-server партнёрству серверного узла. + +Они используются для: + +- синхронизации серверных данных; +- синхронизации пользовательских блокчейнов SHiNE; +- общей межсерверной координации. + +`sync_servers` не являются списком пользовательских серверов доставки DM. + +### 7.3. Несколько серверов у отправителя и получателя + +Протокол должен поддерживать ситуацию, когда: + +- у отправителя несколько `access_servers`; +- у получателя несколько `access_servers`; +- часть серверов у сторон совпадает; +- часть серверов уникальна. + +Из этого следуют требования: + +- все DM-операции должны быть идемпотентны; +- повторное получение уже известного события не должно ломать состояние; +- дубль tombstone должен быть безопасен; +- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone. + +## 8. Методы и их роли + +### 8.1. Существующие методы, которые сохраняются + +- `SendMessagePair` +- `ReceiveOutcomingMessage` +- `ReceiveIncomingMessage` +- `DeleteMessage` +- `DeleteConversation` + +Их роли в v1: + +- `SendMessagePair` — новая пара сообщений и редактирование старой пары автором; +- `ReceiveIncomingMessage` — приём одной входящей копии, входящих редактирований и входящего read-receipt; +- `ReceiveOutcomingMessage` — алиас `SendMessagePair`. + +### 8.2. Новые методы, которые нужны + +Отдельный legacy-метод `SendDirectMessage` в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки. + +## 9. Правила валидации и применения + +### 9.1. Общее правило по ревизиям + +Для одного и того же контентного сообщения сервер сравнивает пару: + +- `revisionTimeMs`; +- `reencryptedAtMs`. + +Правило: + +- если новый `revisionTimeMs` больше сохранённого, сообщение применяется; +- если `revisionTimeMs` равен, но новый `reencryptedAtMs` больше сохранённого, сообщение применяется; +- если обе величины равны, сообщение не применяется; +- если новая пара (`revisionTimeMs`, `reencryptedAtMs`) меньше или равна сохранённой, сообщение не применяется. + +Содержимое `body` при этом сравнении не участвует. + +То есть если сервер уже видел ту же пару (`revisionTimeMs`, `reencryptedAtMs`), он считает, что такая версия у него уже есть. + +### 9.2. Повторный tombstone + +Повторный tombstone означает ситуацию, когда сервер повторно получает то же самое событие удаления: + +- того же сообщения; +- или той же переписки. + +Это может произойти из-за: + +- повторной межсерверной доставки; +- нескольких `access_servers`; +- сетевых retry; +- дублирующей пересылки с разных маршрутов. + +Правило: + +- повторный tombstone должен быть полностью безопасен; +- если соответствующее удаление уже сохранено, сервер ничего не меняет и просто игнорирует повтор. + +### 9.3. Сообщения старше границы удалённой переписки + +Если для пары пользователей уже есть сохранённая граница удаления переписки, и приходит: + +- обычное сообщение; +- удаление одного сообщения; +- повторное удаление переписки; +- любое другое DM-событие; + +у которого `timeMs` меньше этой границы, сервер: + +- ничего не меняет в БД; +- не восстанавливает старую историю; +- не применяет это событие повторно. + +Если это старое контентное сообщение или входящая копия, сервер дополнительно перерассылает уже известный tombstone удаления переписки на `access_servers` обеих сторон, чтобы отстающие серверы сами подчистили историю. + +Такие сообщения считаются частью уже удалённой истории. + +### 9.4. Приоритет удаления переписки + +Если сначала пришло удаление переписки, а потом удаление одного старого сообщения из этой переписки, сервер должен: + +- проигнорировать это удаление одного сообщения; +- не создавать новых изменений поверх уже удалённой истории. + +То же правило действует и для обычных сообщений, и для редактирований старых сообщений. + +## 10. JSON API v1 + +### 10.1. `SendMessagePair` + +Назначение: + +- клиент отправляет новый DM; +- клиент отправляет редактирование старого DM; +- клиент отправляет парную новую ревизию типов `1/2`. + +Request: + +```json +{ + "op": "SendMessagePair", + "requestId": "req-123", + "payload": { + "incomingBlobB64": "...", + "outgoingBlobB64": "..." + } +} +``` + +Правила: + +- клиенту достаточно отправить пару на один любой доступный сервер; +- сервер после принятия сам отвечает за дальнейшую межсерверную доставку. + +### 10.2. `ReceiveIncomingMessage` + +Назначение: + +- приём одной входящей копии по схеме server-to-server; +- приём входящего редактирования; +- приём входящего read-receipt. + +Request: + +```json +{ + "op": "ReceiveIncomingMessage", + "requestId": "req-456", + "payload": { + "incomingBlobB64": "..." + } +} +``` + +### 10.3. `DeleteMessage` + +Назначение: + +- удалить одно сообщение у обеих сторон. + +Request: + +```json +{ + "op": "DeleteMessage", + "requestId": "req-789", + "payload": { + "blobB64": "..." + } +} +``` + +Ожидается контейнер типа: + +- `5`, если удаление инициировал отправитель; +- `6`, если удаление инициировал получатель. + +UI-следствие для клиента: + +- пользователь может удалить как своё исходящее сообщение, так и входящее; +- для удаления входящего UI должен отправлять вариант, где удаление инициировал получатель (`type=6`). + +### 10.4. `DeleteConversation` + +Назначение: + +- удалить всю переписку до времени самого служебного сообщения. + +Request: + +```json +{ + "op": "DeleteConversation", + "requestId": "req-790", + "payload": { + "blobB64": "..." + } +} +``` + +Ожидается контейнер типа: + +- `7`, если удаление инициировал отправитель; +- `8`, если удаление инициировал получатель. + +UI-следствие для клиента: + +- действие `Очистить историю` должно отправлять служебное сообщение удаления переписки; +- для обычной клиентской кнопки очистки истории допускается вариант удаления, инициированный текущим получателем (`type=8`); +- после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории; +- отдельное UI-действие `Удалить чат` может дополнительно спросить, нужно ли вместе с удалением контакта также отправить `DeleteConversation`. + +## 11. Межсерверная доставка + +### 11.1. Клиентская сторона + +Клиенту достаточно отправить сообщение на: + +- любой один доступный сервер. + +### 11.2. Серверная сторона + +После принятия валидного события сервер должен отправлять его: + +- на все серверы из `access_servers` отправителя; +- на все серверы из `access_servers` получателя. + +Если часть серверов совпадает, это допустимо. + +Если один и тот же сервер присутствует у обеих сторон, он не должен слать сообщение сам себе повторно, но обязан локально сохранить событие и доставить его в нужные пользовательские сессии. + +Идемпотентность обязательна. + +### 11.3. Ошибки доставки + +Если часть серверов временно недоступна: + +- это не должно отменять локальное принятие уже валидного сообщения; +- повторная доставка может делаться отдельным retry-механизмом; +- повторное получение того же события должно быть безопасным. + +## 12. Хранение в БД + +Основная таблица остаётся: + +- `signed_messages_v2` + +В ней должны сохраняться: + +- обычные контентные DM; +- tombstone одного сообщения; +- tombstone удаления переписки. + +Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы: + +- защищать от повторного приёма старых версий; +- не терять факт удаления; +- корректно синхронизировать событие между серверами. + +Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются. + +## 13. Что обязательно должно измениться в коде относительно v0.5 + +- сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`; +- сервер не должен трактовать `encryptedBody` как обычный UTF-8 текст; +- сервер не должен валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`; +- DM должны реально шифроваться end-to-end; +- удаление одного сообщения должно стать терминальным tombstone; +- удаление всей переписки должно стать отдельным служебным событием; +- межсерверная маршрутизация DM должна идти через `access_servers`; +- сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM; +- при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает; +- legacy `SendDirectMessage` должен быть отключён; +- логика должна быть безопасна для нескольких серверов у каждой стороны. + +## 14. Что в v1 пока не входит + +- вложения в DM; +- хранение отдельного `keyId` шифрования в DM; +- ротация `clientKey`; +- финальная конкретная UI-реализация массовой перешифровки; +- физическая полная реализация DM federation в текущем коде. diff --git a/Dev_Docs/Personal_Messages/README.md b/docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md similarity index 73% rename from Dev_Docs/Personal_Messages/README.md rename to docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md index 817e6e9..15cd509 100644 --- a/Dev_Docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md @@ -1,4 +1,20 @@ -# Личные сообщения (DM) +# Личные сообщения (DM) — v0.5 устаревшая спецификация + +## Статус документа + +Этот документ устарел и сохранён только как историческое описание ранее реализованной схемы DM. + +Aктуальная целевая спецификация: + +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` + +Что в этом документе считать устаревшим: + +- трактовку `encryptedBody` как фактически одинакового содержимого пары; +- отсутствие нормального E2EE-шифрования DM; +- старую модель удаления через пустую ревизию без терминального tombstone; +- старую трактовку обновления только как общей пары без отдельной будущей модели перешифровки; +- все упоминания legacy-формата read-receipt как части целевой архитектуры следующего этапа. ## Текущее состояние @@ -184,6 +200,15 @@ Read-receipt пока остаются в legacy-формате: - `AckSessionDelivery` +WebPush и локальные уведомления сейчас работают так: + +- для активной онлайн-сессии приоритет у доставки по WebSocket через `SignedMessageArrived`; +- если целевая сессия не онлайн по WebSocket, сервер может отправить WebPush с `kind=new_message`; +- если вкладка/приложение живы, но страница скрыта (`document.visibilityState !== visible`), UI дополнительно пытается показать системное уведомление через `service worker`; +- для активной видимой страницы UI проигрывает короткий локальный сигнал на каждое новое входящее DM, если браузер ранее разрешил аудио-контекст после пользовательского жеста; +- для скрытой, но живой страницы UI также делает `best effort` сигнал через `vibrate()` и более длинный локальный звук; +- эти локальные сигналы не гарантируются браузером: на мобильных устройствах они зависят от политики Chrome/Android/iOS. + ## Правила UI UI сейчас работает так: @@ -194,6 +219,8 @@ UI сейчас работает так: - позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`; - при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены; - после редактирования показывает под временем отдельную строку `изменено: <дата время>`; +- на видимом экране чата/приложения проигрывает короткий локальный звук на новое входящее DM; +- при входящем DM для скрытой, но ещё живой страницы пытается поднять системное уведомление через `service worker`; - не показывает и не принимает вложения. ## Что обязательно помнить diff --git a/docs/Personal_Messages/Технические_вставки_DM_v1.md b/docs/Personal_Messages/Технические_вставки_DM_v1.md new file mode 100644 index 0000000..ded7a5f --- /dev/null +++ b/docs/Personal_Messages/Технические_вставки_DM_v1.md @@ -0,0 +1,147 @@ +# Технические вставки внутри plaintext DM v1 + +## Статус документа + +Этот файл описывает внутренний формат технических вставок, которые находятся внутри уже расшифрованного plaintext DM. + +Важно: + +- это не отдельный серверный DM-envelope; +- это часть plaintext контентного DM после E2EE-расшифровки; +- сервер не обязан знать этот формат; +- клиент может использовать эти вставки для специального UI-рендера. + +## 1. Общий принцип + +Обычное сообщение по-прежнему остаётся обычным текстом. + +Если в начале plaintext стоит один или несколько специальных блоков формата: + +```text + +``` + +то клиент трактует их как технические вставки. + +Техническими считаются только блоки, которые: + +- стоят строго в начале plaintext; +- начинаются с точного префикса ``. + +Если текст не начинается с `` в начале plaintext: + +- не показываются пользователю как сырой текст; +- используются клиентом для UI-логики; +- неизвестные будущие типы тоже скрываются, если они распознаны как корректный SHiNE-блок. + +Если блок битый и не закрыт символом `>`, он не считается техническим и сообщение показывается как обычный текст целиком. + +## 3. Защита от случайного пользовательского ввода + +Перед отправкой обычного текстового сообщения клиент обязан проверить: + +- если пользовательский текст начинается с ` +``` + +Правила: + +- `kind` — ASCII-идентификатор типа вставки; +- параметры отделяются `;`; +- ключ и значение отделяются `=`; +- значения не экранируются в v1; +- формат чувствителен к точному префиксу `Текст ответа +``` + +Где поле `id` — это логический идентификатор сообщения: + +```text +fromLogin|toLogin|timeMs|nonce +``` + +Правила: + +- этот блок должен стоять в начале plaintext; +- после него может идти обычный текст ответа; +- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения; +- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview; +- в таком случае само сообщение отображается как обычный текст без блока ``. + +## 6. Тип `call` + +### Успешный звонок + +```text + +``` + +Где: + +- `duration` — длительность разговора в секундах. + +### Неуспешный звонок + +```text + +``` + +Допустимые причины в v1: + +- `offline` +- `no_answer` +- `connect_failed` +- `busy` +- `declined` + +Правила: + +- call-блок в v1 должен содержать только техническую запись звонка; +- пользовательский текст после такого блока в штатной логике не предполагается; +- UI может рисовать такие сообщения отдельным специальным стилем. +- официальный UI не отправляет call-summary, если от старта исходящего звонка до его завершения прошло меньше `5` секунд. + +## 7. Поведение официального UI + +Официальный UI SHiNE в v1: + +- скрывает все корректные `` блоки в начале plaintext; +- для `call` строит специальный человекочитаемый текст: + - `Звонок: M:SS` + - `Звонок: H:MM:SS` + - `Звонил, но недозвонился: ...` +- для `reply` скрывает сам блок и показывает только текст ответа; +- если исходное reply-сообщение не найдено, reply-preview не показывается. + +## 8. Совместимость + +Так как это часть plaintext, а не часть серверного envelope: + +- сервер не обязан понимать этот формат; +- будущие клиенты могут добавлять новые `kind`; +- клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md new file mode 100644 index 0000000..2132bdc --- /dev/null +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -0,0 +1,329 @@ +# Формат DM v1 + +## Статус документа + +Этот файл фиксирует байтовый формат DM-контейнеров SHiNE v1. + +Важно: + +- это актуальный формат, реализованный в текущем коде; +- контейнер `SHiNE_DM` используется для всех типов `1..8`; +- при любых будущих изменениях DM этот документ обновляется одновременно с кодом. + +Логика протокола, API и поведение сервера описаны отдельно: + +- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` + +## 1. Общие правила + +- Числа кодируются в `big-endian`. +- `toLogin` и `fromLogin` — ASCII строки длиной `1..60`. +- Подпись — `Ed25519`, 64 байта. +- `nonce` — `u32`. +- `messageType` — `u8`. +- `timeMs`, `revisionTimeMs`, `reencryptedAtMs` — `u64`. + +## 2. Типы сообщений + +- `1` — входящее сообщение +- `2` — исходящая копия сообщения +- `3` — входящее подтверждение прочтения +- `4` — исходящая копия подтверждения прочтения +- `5` — сообщение удалено отправителем +- `6` — сообщение удалено получателем +- `7` — переписка удалена отправителем +- `8` — переписка удалена получателем + +## 3. Контейнер `SHiNE_DM` + +```text +SHiNE_DM +- prefix = "SHiNE_DM" +- formatVersionMajor: u8 +- formatVersionMinor: u8 +- toLoginLen: u8 +- toLogin +- fromLoginLen: u8 +- fromLogin +- timeMs: u64 +- nonce: u32 +- messageType: u8 +- revisionTimeMs: u64 +- reencryptedAtMs: u64 +- bodyLen: u32 +- body +- signature: [64] +``` + +## 4. Смысл полей `SHiNE_DM` + +### `timeMs` + +Это время исходного создания сообщения. + +Для типов `7/8` это время самого служебного сообщения удаления переписки и одновременно граница, раньше которой сообщения этой пары считаются недействительными. + +### `nonce` + +Дополнительное поле уникальности сообщения. + +Используется вместе с `timeMs` для построения стабильного логического ID. + +### `messageType` + +Определяет смысл контейнера: + +- обычное сообщение; +- исходящая копия; +- read-receipt; +- удаление одного сообщения; +- удаление всей переписки. + +### `revisionTimeMs` + +- `0` для исходной версии контентного сообщения; +- ненулевое значение для новой текстовой ревизии сообщения; +- для типов `5/6` это время tombstone удаления одного сообщения; +- для типов `7/8` всегда `0`. + +То есть `revisionTimeMs` отвечает именно за изменение смыслового содержимого сообщения, а не за сам факт перешифровки. + +### `reencryptedAtMs` + +- `0`, если перешифровки не было; +- ненулевое значение, если копия сообщения была перешифрована. + +Для типов `5/6/7/8` должно быть `0`. + +`reencryptedAtMs` не требует, чтобы `revisionTimeMs` был ненулевым. Допустим сценарий: + +- `revisionTimeMs = 0` +- `reencryptedAtMs > 0` + +если исходное сообщение не редактировалось, но было перешифровано позже. + +### `bodyLen` + +- `> 0` для типов `1/2/3/4`; +- `0` для типов `5/6/7/8`; +- для всех типов используется один и тот же контейнер `SHiNE_DM`. + +## 5. Поле `body` для типов `1/2` + +Для обычного контентного DM поле `body` содержит не голый ciphertext, а контейнер шифрования: + +```text +EncryptedBody_v1_0 +- cryptoMethod: u8 +- cryptoVersion: u8 +- ephemeralPubKeyLen: u8 +- ephemeralPubKey: bytes[ephemeralPubKeyLen] +- ivLen: u8 +- iv: bytes[ivLen] +- cipherTextLen: u32 +- cipherText: bytes[cipherTextLen] +``` + +### Смысл полей `EncryptedBody_v1_0` + +#### `cryptoMethod` + +Идентификатор метода шифрования. + +В версии v1 резервируется: + +- `1` — `X25519 + HKDF-SHA256 + AES-256-GCM` + +#### `cryptoVersion` + +Версия конкретного метода шифрования. + +Для текущей версии: + +- `0` + +#### `ephemeralPubKey` + +Публичный ephemeral `X25519` ключ отправителя конкретной ревизии. + +Практически ожидается длина: + +- `32` + +#### `iv` + +Nonce/IV для `AES-GCM`. + +Практически ожидается длина: + +- `12` + +#### `cipherText` + +Зашифрованное содержимое сообщения. + +Сервер не должен трактовать это поле как UTF-8 текст и не должен пытаться расшифровывать его в обычной DM-логике. + +Важно: + +- официальный UI SHiNE сейчас использует именно `EncryptedBody_v1_0`; +- сервер для контентных DM `type=1/2` не должен требовать, чтобы `body` обязательно был распознан как `EncryptedBody_v1_0`; +- сервер должен хранить и пересылать `body` как opaque bytes, если внешний контейнер `SHiNE_DM` валиден. + +### Точная схема для `cryptoMethod = 1`, `cryptoVersion = 0` + +- публичный ключ получателя для E2EE получается как стандартное преобразование `Ed25519 -> X25519`; +- отправитель генерирует ephemeral `X25519` приватный ключ и кладёт соответствующий `ephemeralPubKey` в контейнер; +- общий секрет вычисляется как `X25519(ephemeralPrivKey, recipientX25519PubKey)`; +- `HKDF-SHA256` использует: + - `salt = ephemeralPubKey || recipientX25519PubKey` + - `info = "SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM"` в ASCII + - `outputLen = 32` +- полученные `32` байта используются как ключ `AES-256-GCM`; +- поле `cipherText` хранит стандартный результат библиотеки `AES-GCM` в виде: + - `ciphertext || tag` + +## 6. Поле `body` для типов `3/4` + +Для read-receipt типов `3/4` поле `body` содержит открытый контейнер ссылки на исходное сообщение: + +```text +ReadReceiptBody_v1_0 +- refToLoginLen: u8 +- refToLogin +- refFromLoginLen: u8 +- refFromLogin +- refTimeMs: u64 +- refNonce: u32 +``` + +Смысл: + +- `refToLogin` +- `refFromLogin` +- `refTimeMs` +- `refNonce` + +однозначно указывают на логическое сообщение по его `baseKey`. + +Отдельный `refType` в v1 не нужен, потому что подтверждение прочтения относится к самому логическому сообщению, а не к выбору между копиями `type=1` и `type=2`. +## 7. Контент типов `1/2` + +Для типов `1/2`: + +- `bodyLen > 0` +- `body` обязан быть контейнером `EncryptedBody_v1_0` +- ciphertext у `type=1` и `type=2` может быть разным + +### Новое сообщение + +- `revisionTimeMs = 0` +- `reencryptedAtMs = 0` + +### Редактирование + +- `revisionTimeMs > 0` +- `reencryptedAtMs = 0` или больше нуля, если одновременно произошла перешифровка + +### Перешифровка + +- `revisionTimeMs = 0` или `> 0` +- `reencryptedAtMs > 0` + +## 8. Контент типов `3/4` + +Типы: + +- `3` — входящее подтверждение прочтения +- `4` — исходящая копия подтверждения прочтения + +Правила: + +- `bodyLen > 0` +- `body` обязан быть контейнером `ReadReceiptBody_v1_0` +- `revisionTimeMs = 0` +- `reencryptedAtMs = 0` + +Тип `3` и тип `4` по стилю полностью подчиняются общему контейнеру `SHiNE_DM`: + +- тот же `prefix = "SHiNE_DM"` +- те же `formatVersionMajor = 1` +- те же `formatVersionMinor = 0` + +Различается только `messageType` и формат `body`. + +## 9. Контент типов `5/6` + +Типы: + +- `5` — сообщение удалено отправителем +- `6` — сообщение удалено получателем + +Правила: + +- `bodyLen = 0` +- `body` отсутствует +- `revisionTimeMs > 0` +- `reencryptedAtMs = 0` + +Это terminal tombstone конкретного сообщения. + +После принятия такого контейнера содержательная версия этого же `messageKey` больше не должна приниматься. + +## 10. Контент типов `7/8` + +Типы: + +- `7` — переписка удалена отправителем +- `8` — переписка удалена получателем + +Правила: + +- `bodyLen = 0` +- `body` отсутствует +- `revisionTimeMs = 0` +- `reencryptedAtMs = 0` + +Здесь роль границы удаления играет: + +- `timeMs` самого контейнера + +После принятия такого контейнера: + +- более старые сообщения этой пары должны быть удалены; +- более старые новые поступления этой пары не должны приниматься. + +## 11. Подпись + +Поле `signature` всегда подписывает весь контейнер от `prefix` до конца `body` включительно. + +Правило подписи по `messageType`: + +- `1`, `2`, `5`, `7` подписывает отправитель сообщения; +- `6`, `8` подписывает получатель сообщения. + +Практическое UI-следствие: + +- клиентский UI может инициировать удаление входящего сообщения через `type=6`; +- клиентский UI может инициировать очистку истории переписки через `type=8`; +- сами контейнеры и байтовый формат при этом не отличаются от уже описанных типов `5/6/7/8`. + +## 12. Какие поля должны совпадать у пары `1/2` + +При обычной парной отправке через `SendMessagePair` у двух копий должны совпадать: + +- `fromLogin` +- `toLogin` +- `timeMs` +- `nonce` +- `revisionTimeMs` + +Дополнительно: + +- логический plaintext должен быть одинаковым; +- ciphertext может различаться; +- `reencryptedAtMs` при одной парной ревизии тоже должен совпадать. + +## 13. Примечание о поддержке + +В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`. diff --git a/Dev_Docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md similarity index 96% rename from Dev_Docs/Solana/user_pda/README.md rename to docs/Solana/user_pda/README.md index 6039f04..ea1dc0a 100644 --- a/Dev_Docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -273,7 +273,7 @@ ServerProfileBlock - `address_format_version` — версия формата адреса, сейчас `0`; - `sync_servers_count` максимум `32`; - `server_address` - строковый адрес сервера в соответствии с `address_format_type`; -- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. +- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует серверные данные и пользовательские блокчейны SHiNE. Это server-to-server список партнёров самого серверного узла, а не список серверов доставки личных сообщений для обычного пользователя. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. ## 13. AccessServersBlock @@ -291,7 +291,7 @@ AccessServersBlock - блок может отсутствовать, если серверы доступа не заданы; - список может обновляться при изменении маршрутизации пользователя; -- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; +- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay для конкретного пользователя. Через этот список клиентская и серверная логика SHiNE может маршрутизировать доставку личных сообщений, доступ к сессиям и другие пользовательские операции. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; - точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. ## 14. SessionsBlock diff --git a/Dev_Docs/Solana_Architecture/README.md b/docs/Solana_Architecture/README.md similarity index 92% rename from Dev_Docs/Solana_Architecture/README.md rename to docs/Solana_Architecture/README.md index e28e129..ebacfe6 100644 --- a/Dev_Docs/Solana_Architecture/README.md +++ b/docs/Solana_Architecture/README.md @@ -16,7 +16,7 @@ В Solana-модуле сейчас три основные программы: -1. `shine_login_guard` — проверяет логин и возвращает класс логина: обычный, premium или trademark. +1. `shine_login_guard` — в текущем временном режиме проверяет базовую валидность логина и пропускает on-chain любые валидные логины длиной от 5 символов. 2. `shine_users` — создает и обновляет пользовательскую PDA-запись, проверяет подписи и берет оплату за регистрацию/увеличение лимита. 3. `shine_payments` — принимает входящий поток средств в `inflow_vault`, ведет очереди тикетов, позволяет DAO выдавать лимиты менеджерам и выполняет выплаты. @@ -35,7 +35,7 @@ DAO в текущем виде не является отдельной Anchor- | Блок | Папка/имя | Текущие функции из кода | Основной смысл | | --- | --- | --- | --- | -| 1 | `shine_login_guard` | `classify_login` | Проверка логина перед регистрацией. | +| 1 | `shine_login_guard` | `classify_login` | Временная on-chain проверка логина перед регистрацией. | | 2 | `shine_users` | `init_users_economy_config`, `update_users_economy_config`, `create_user_pda`, `update_user_pda` | Регистрация пользователя, обновление записи, экономика лимита. | | 3 | `shine_payments` | `init`, `update_coef_limit`, `grant_manager_limits`, `buy_ticket`, `buy_ticket_usd`, `buy_ticket_sol`, `manager_add_ticket`, `step_payout`, `change_ticket_recipient` | Vault, билеты, очереди, выплаты, DAO-настройки, лимиты менеджеров. | | DAO | governance/authority | Вызовы через governance и управляющие ключи | Управление правами, казной, настройками и будущими обновлениями программ. | @@ -46,9 +46,9 @@ DAO в текущем виде не является отдельной Anchor- | Программа | Program ID | | --- | --- | -| `shine_login_guard` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` | -| `shine_users` | `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` | -| `shine_payments` | `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` | +| `shine_login_guard` | `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` | +| `shine_users` | `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` | +| `shine_payments` | `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` | Если эти адреса меняются, нужно синхронно обновить: @@ -62,7 +62,7 @@ DAO в текущем виде не является отдельной Anchor- Для удобного понимания на старте можно считать, что есть четыре группы ключей: 1. `key_1` / authority программы `shine_login_guard`. - - Сейчас программа только классифицирует логин. + - Сейчас программа только выполняет временную базовую проверку логина. - На первом этапе ее можно оставить под отдельным ключом. - В будущем право обновления можно передать DAO. @@ -140,7 +140,7 @@ Solana-модуль лежит в основном репозитории как 1. `shine_login_guard` - Пока оставить на отдельном ключе `key_1`. - - Передачу DAO сделать позже, когда логика premium/trademark стабилизируется. + - Передачу DAO сделать позже, когда временный режим будет заменён на постоянную логику промокодов и/или premium/trademark. 2. `shine_users` - Economy-настройки уже должны обновляться DAO-authority. diff --git a/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md b/docs/Solana_Architecture/details/accounts_and_money_flow.md similarity index 91% rename from Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md rename to docs/Solana_Architecture/details/accounts_and_money_flow.md index 6fbe6c6..43786e7 100644 --- a/Dev_Docs/Solana_Architecture/details/accounts_and_money_flow.md +++ b/docs/Solana_Architecture/details/accounts_and_money_flow.md @@ -26,11 +26,11 @@ | Роль | Адрес | | --- | --- | -| `SHINE_LOGIN_GUARD_PROGRAM_ID` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` | -| `SHINE_USERS_PROGRAM_ID` | `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` | -| `SHINE_PAYMENTS_PROGRAM_ID` | `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` | -| `DAO_AUTHORITY` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` | -| `DAO_TREASURY_WALLET` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` | +| `SHINE_LOGIN_GUARD_PROGRAM_ID` | `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` | +| `SHINE_USERS_PROGRAM_ID` | `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` | +| `SHINE_PAYMENTS_PROGRAM_ID` | `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` | +| `DAO_AUTHORITY` | `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq` | +| `DAO_TREASURY_WALLET` | `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq` | ## Постоянные PDA diff --git a/Dev_Docs/Solana_Architecture/details/shine_dao.md b/docs/Solana_Architecture/details/shine_dao.md similarity index 100% rename from Dev_Docs/Solana_Architecture/details/shine_dao.md rename to docs/Solana_Architecture/details/shine_dao.md diff --git a/Dev_Docs/Solana_Architecture/details/shine_login_guard.md b/docs/Solana_Architecture/details/shine_login_guard.md similarity index 100% rename from Dev_Docs/Solana_Architecture/details/shine_login_guard.md rename to docs/Solana_Architecture/details/shine_login_guard.md diff --git a/Dev_Docs/Solana_Architecture/details/shine_payments.md b/docs/Solana_Architecture/details/shine_payments.md similarity index 100% rename from Dev_Docs/Solana_Architecture/details/shine_payments.md rename to docs/Solana_Architecture/details/shine_payments.md diff --git a/Dev_Docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md similarity index 100% rename from Dev_Docs/Solana_Architecture/details/shine_users.md rename to docs/Solana_Architecture/details/shine_users.md diff --git a/Dev_Docs/Solana_Architecture/schemes/architecture.mmd b/docs/Solana_Architecture/schemes/architecture.mmd similarity index 100% rename from Dev_Docs/Solana_Architecture/schemes/architecture.mmd rename to docs/Solana_Architecture/schemes/architecture.mmd diff --git a/Dev_Docs/Solana_Architecture/schemes/architecture.png b/docs/Solana_Architecture/schemes/architecture.png similarity index 100% rename from Dev_Docs/Solana_Architecture/schemes/architecture.png rename to docs/Solana_Architecture/schemes/architecture.png diff --git a/Dev_Docs/Solana_Architecture/schemes/architecture.svg b/docs/Solana_Architecture/schemes/architecture.svg similarity index 100% rename from Dev_Docs/Solana_Architecture/schemes/architecture.svg rename to docs/Solana_Architecture/schemes/architecture.svg diff --git a/Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md b/docs/audit/Solana-audit-2-by-Claude-11июня2026.md similarity index 100% rename from Dev_Docs/audit/Solana-audit-2-by-Claude-11июня2026.md rename to docs/audit/Solana-audit-2-by-Claude-11июня2026.md diff --git a/Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md b/docs/audit/Solana-audit-3-by-Claude-12июня2026.md similarity index 100% rename from Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md rename to docs/audit/Solana-audit-3-by-Claude-12июня2026.md diff --git a/Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md b/docs/audit/Solana-audit-by-Claude-File5-9июня2026.md similarity index 100% rename from Dev_Docs/audit/Solana-audit-by-Claude-File5-9июня2026.md rename to docs/audit/Solana-audit-by-Claude-File5-9июня2026.md diff --git a/Dev_Docs/deploy/README.md b/docs/deploy/README.md similarity index 79% rename from Dev_Docs/deploy/README.md rename to docs/deploy/README.md index 0459063..fc1a9c6 100644 --- a/Dev_Docs/deploy/README.md +++ b/docs/deploy/README.md @@ -18,12 +18,12 @@ - Production: - SSH: `player@shineup.me` - Домен: `shineup.me` - - IP: `185.229.109.118` -- Main test: + - IP: `178.208.64.62` +- Second production: - SSH: `player@193.8.215.70` - - Домен: `test2.shineup.me` + - Домен: `t.shineup.me` - IP: `193.8.215.70` -- Reserve test: +- Reserve test (temporarily down): - SSH: `player@93.170.12.154` - Домен: `test.shineup.me` - IP: `93.170.12.154` @@ -38,16 +38,21 @@ - Reserve test UI deploy: `./gradlew deployUITest` - Локальный запуск: `./gradlew startLocal` +## Отдельные тестовые VPS + +- Отдельный стенд с 4 devnet-серверами на одном VPS описан в: + - `Dev_Docs/deploy/test-server/README.md` + ## Политика подтверждений -- `shineup.me` — production. -- Любые изменения на `shineup.me`, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя. -- Если пользователь пишет просто `задеплой` без уточнения, по умолчанию это означает deploy на `test2.shineup.me`. +- `shineup.me` и `t.shineup.me` считать production-контурами. +- Любые изменения на production-контурах, включая deploy сервера, deploy UI, конфиги, перезапуски и миграции, делать только после отдельного явного подтверждения пользователя. +- `test.shineup.me` считать резервным тестовым хостом, который сейчас временно не работает. -## Main test deploy (`test2.shineup.me`) +## Second production deploy (`t.shineup.me`) -- Это основной сервер для тестов. -- `deployServer` и `deployUI` по умолчанию направлены именно сюда. +- Это второй production-сервер SHiNE. +- Исторические задачи `deployServer`, `deployUI`, `deployServerTest2`, `deployUITest2` по-прежнему могут указывать сюда. - Серверный deploy не запускает JUnit/IT-тесты на удалённом сервере. - `deployServer` / `deployServerTest2` делают: - сборку fat-jar локально; @@ -55,12 +60,14 @@ - перенос `application.properties` с production с поправкой `server.ui.indexPath` на `/home/player/SHiNE/shine-ui/index.html`; - установку `systemd` unit на `193.8.215.70`; - перезапуск `shine-server.service`; - - установку/проверку Caddy для `test2.shineup.me`. + - установку/проверку Caddy для `t.shineup.me`. - `deployUI` / `deployUITest2` публикуют UI в `/home/player/SHiNE/shine-ui` на `193.8.215.70`. ## Reserve test deploy (`test.shineup.me`) -- `test.shineup.me` пока не использовать для обычного deploy. +- `test.shineup.me` считается резервным тестовым сервером. +- Сейчас он помечен как временно неработающий. +- Его настройки и адрес не менять без отдельной задачи. - Задачи `deployServerTest` и `deployUITest` считаются резервными и требуют отдельной причины. ## UI-деплой и Caddy (обязательно) diff --git a/Dev_Docs/deploy/agent-bot-coder-local-systemd.md b/docs/deploy/agent-bot-coder-local-systemd.md similarity index 100% rename from Dev_Docs/deploy/agent-bot-coder-local-systemd.md rename to docs/deploy/agent-bot-coder-local-systemd.md diff --git a/Dev_Docs/deploy/servers/193.8.215.70_test2_main.md b/docs/deploy/servers/193.8.215.70_test2_main.md similarity index 63% rename from Dev_Docs/deploy/servers/193.8.215.70_test2_main.md rename to docs/deploy/servers/193.8.215.70_test2_main.md index fbc36d5..242f98c 100644 --- a/Dev_Docs/deploy/servers/193.8.215.70_test2_main.md +++ b/docs/deploy/servers/193.8.215.70_test2_main.md @@ -1,7 +1,8 @@ -# Сервер `193.8.215.70` — основной test (`test2.shineup.me`) +# Сервер `193.8.215.70` — второй production (`t.shineup.me`) - Пользователь: `player` -- Домен: `test2.shineup.me` +- Домен: `t.shineup.me` +- Логин сервера: `tshineupme` - Каталог SHiNE: `/home/player/SHiNE` - UI публикация для Caddy: `/home/player/SHiNE/shine-ui` - Сервер: `/home/player/SHiNE/shine-server/shine-server.jar` @@ -17,17 +18,17 @@ ## Статус -- Это основной сервер для тестов SHiNE. -- Default deploy по умолчанию должен идти сюда. -- Источник данных для тестовой БД: production `shineup.me`. +- Это второй production-сервер SHiNE. +- Исторические имена deploy-задач могут по-прежнему ссылаться на него как на `test2`. +- В документации и в списке окружений считать его production-контуром. ## Caddy - Конфиг: `/etc/caddy/Caddyfile` - Сайты: - - `test2.shineup.me` + - `t.shineup.me` - `agent.shiningpeople.ru` -- Для `test2.shineup.me`: +- Для `t.shineup.me`: - `root * /home/player/SHiNE/shine-ui` - `try_files {path} /index.html` - `reverse_proxy /ws* -> 127.0.0.1:7070` diff --git a/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md b/docs/deploy/servers/93.170.12.154_rapsberry.md similarity index 85% rename from Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md rename to docs/deploy/servers/93.170.12.154_rapsberry.md index 9c0e4df..370281d 100644 --- a/Dev_Docs/deploy/servers/93.170.12.154_rapsberry.md +++ b/docs/deploy/servers/93.170.12.154_rapsberry.md @@ -1,8 +1,9 @@ -# Сервер `93.170.12.154` — test.shineup.me +# Сервер `93.170.12.154` — резервный test (`test.shineup.me`) - Пользователь: `player` - Каталог SHiNE: `/home/player/SHiNE` - Домен: `test.shineup.me` +- Роль: резервный тестовый сервер - UI публикация для Caddy: `/home/player/SHiNE/shine-ui` - Сервер: `/home/player/SHiNE/shine-server/shine-server.jar` - Данные: `/home/player/SHiNE/shine-server/data/` @@ -18,6 +19,7 @@ ## Статус - Резервный тестовый сервер для SHiNE. +- Сейчас помечен как временно неработающий. - Источник данных для тестовой БД: production `shineup.me`. - Пока не использовать для обычного deploy. - Основной прод-сервер: `shineup.me` (`185.229.109.118`). diff --git a/Dev_Docs/deploy/servers/shineup.me_main.md b/docs/deploy/servers/shineup.me_main.md similarity index 93% rename from Dev_Docs/deploy/servers/shineup.me_main.md rename to docs/deploy/servers/shineup.me_main.md index 66700ba..3e94738 100644 --- a/Dev_Docs/deploy/servers/shineup.me_main.md +++ b/docs/deploy/servers/shineup.me_main.md @@ -2,6 +2,7 @@ - SSH: `player@shineup.me` - Определение IP: через DNS-резолв домена `shineup.me` на момент подключения +- Текущий IP на момент последнего обновления документа: `178.208.64.62` - Пользователь: `player` - Базовый путь: `/home/player` - Каталог SHiNE: `/home/player/SHiNE` @@ -17,7 +18,7 @@ ## Caddy -- Активный конфиг (через systemd `ExecStart`): `/home/player/SHiNE/caddy/Caddyfile` +- Активный конфиг (через systemd `ExecStart`): `/etc/caddy/Caddyfile` - Для UI: - `root * /home/player/SHiNE/shine-ui` - `try_files {path} /index.html` (SPA fallback) diff --git a/docs/deploy/test-server/178.208.90.249_quad_devnet.md b/docs/deploy/test-server/178.208.90.249_quad_devnet.md new file mode 100644 index 0000000..adda81e --- /dev/null +++ b/docs/deploy/test-server/178.208.90.249_quad_devnet.md @@ -0,0 +1,173 @@ +# VPS `178.208.90.249` (`player`) — 4 тестовых SHiNE-сервера на devnet + +## Назначение + +Этот VPS используется как отдельный тестовый стенд для одновременного запуска 4 SHiNE-серверов: +- `server_t1` → `https://t1.shineup.me` +- `server_t2` → `https://t2.shineup.me` +- `server_t3` → `https://t3.shineup.me` +- `server_t4` → `https://t4.shineup.me` + +Все 4 инстанса работают через Solana `devnet`. + +## Каталоги + +Структура в домашней папке пользователя `player`: +- `/home/player/t1/server` +- `/home/player/t1/UI` +- `/home/player/t2/server` +- `/home/player/t2/UI` +- `/home/player/t3/server` +- `/home/player/t3/UI` +- `/home/player/t4/server` +- `/home/player/t4/UI` + +Дополнительно: +- подробная памятка на самом сервере: `/home/player/Agents.md` + +## Порты и systemd + +Соответствие инстансов: +- `t1` → localhost `7101` → `shine-t1.service` +- `t2` → localhost `7102` → `shine-t2.service` +- `t3` → localhost `7103` → `shine-t3.service` +- `t4` → localhost `7104` → `shine-t4.service` + +Каждый unit запускает один и тот же jar: +- `/home/player/tX/server/shine-server.jar` + +Рабочая директория каждого unit: +- `/home/player/tX/server` + +Это важно, потому что сервер читает внешний `application.properties` именно из текущей рабочей директории. + +## Конфиги сервера + +У каждого инстанса свой файл: +- `/home/player/tX/server/application.properties` + +Ключевые параметры в нём: +- `server.port=710X` +- `server.SHiNE.login=server_tX` +- `db.path=data/shine.sqlite` +- `solana.cluster=devnet` +- `solana.rpcUrl=https://api.devnet.solana.com` +- `server.ui.indexPath=/home/player/tX/UI/index.html` +- `server.info.url=https://tX.shineup.me` +- `server.info.origin=devnet` + +Важно: +- Program ID SHiNE одинаковые для mainnet и devnet. +- Поэтому для перевода инстанса на devnet достаточно правильных `solana.cluster` и `solana.rpcUrl`. + +## Конфиги UI + +У каждого домена лежит отдельная копия `shine-UI`. + +В каждой копии меняется `js/deploy-config.js`: +- `defaultServerLogin=server_tX` +- `defaultServerAddress=tX.shineup.me` +- `defaultSolanaCluster=devnet` +- `defaultSolanaEndpoint=https://api.devnet.solana.com` + +За счёт этого: +- UI по умолчанию открывает именно свой сервер +- `server-ui` формы по умолчанию используют именно `devnet` + +## Caddy + +Конфиг: +- `/etc/caddy/Caddyfile` + +Логика по каждому сайту одинаковая: +- статические файлы берутся из `/home/player/tX/UI` +- путь `/ws` проксируется на `127.0.0.1:710X` + +Права доступа: +- каталог `/home/player` и `/home/player/tX` должен быть доступен на `+x` для чтения через Caddy +- все каталоги и файлы в `/home/player/tX/UI` должны быть читаемы пользователем `caddy` + +## Что уже проверено + +После настройки было подтверждено: +- `https://t1.shineup.me` отвечает `HTTP 200` +- `https://t2.shineup.me` отвечает `HTTP 200` +- `https://t3.shineup.me` отвечает `HTTP 200` +- `https://t4.shineup.me` отвечает `HTTP 200` +- `Caddy` успешно выпустил сертификаты Let's Encrypt на все 4 домена +- все 4 процесса Java слушают свои порты `7101..7104` +- в логах есть строка `WS сервер запущен на ws://localhost:710X/ws` + +## Важный operational-нюанс + +Официальный `https://api.devnet.solana.com` может отдавать `HTTP 429`, если несколько инстансов одновременно делают bootstrap PDA/sync-серверов на старте. + +На практике это уже проявилось: +- `t3` и `t4` подтянули `sync_servers` сразу +- `t1` и `t2` на первом одновременном старте словили `429` +- после последовательного рестарта `shine-t1` и `shine-t2` они тоже успешно сохранили `sync_servers` + +Если после очередного деплоя какой-то инстанс не подтянул `sync_servers`, сначала делать не массовый рестарт, а по одному: + +```bash +sudo systemctl restart shine-t1 +sleep 8 +sudo systemctl restart shine-t2 +sleep 8 +sudo systemctl restart shine-t3 +sleep 8 +sudo systemctl restart shine-t4 +``` + +## Как обновлять сервер + +1. Локально собрать jar: + +```bash +./gradlew shadowJar +``` + +2. Залить `SHiNE-server/build/libs/shine-server.jar` в каждый нужный каталог: +- `/home/player/t1/server/shine-server.jar` +- `/home/player/t2/server/shine-server.jar` +- `/home/player/t3/server/shine-server.jar` +- `/home/player/t4/server/shine-server.jar` + +3. Если менялся runtime-конфиг, обновить соответствующий `application.properties`. + +4. Перезапускать сервисы лучше по одному, с паузой, чтобы снизить шанс `429` от devnet RPC. + +## Как обновлять UI + +1. Взять свежую локальную папку `shine-UI/`. +2. Для каждой копии подставить свой `deploy-config.js`. +3. Скопировать файлы в `/home/player/tX/UI/`. +4. Проверить права чтения для Caddy. + +## Полезные команды на VPS + +Проверка сервисов: + +```bash +sudo systemctl --no-pager --full status caddy shine-t1 shine-t2 shine-t3 shine-t4 +``` + +Перезапуск одного инстанса: + +```bash +sudo systemctl restart shine-t1 +``` + +Логи одного инстанса: + +```bash +tail -n 200 /home/player/t1/server/logs/app.log +sudo journalctl -u shine-t1 -n 200 --no-pager +``` + +Проверка Caddy: + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +curl -I https://t1.shineup.me +``` diff --git a/docs/deploy/test-server/README.md b/docs/deploy/test-server/README.md new file mode 100644 index 0000000..e51e7ee --- /dev/null +++ b/docs/deploy/test-server/README.md @@ -0,0 +1,12 @@ +# Тестовый VPS с 4 SHiNE-серверами + +В этой папке описан отдельный тестовый VPS `178.208.90.249`, на котором подняты 4 независимых SHiNE-инстанса под доменами `t1.shineup.me` ... `t4.shineup.me`. + +Текущая основная схема: +- пользователь на VPS: `player` +- все 4 инстанса работают через `devnet` +- каждый инстанс имеет свой каталог `server`, свой каталог `UI`, свой `systemd` unit и свой localhost-порт +- внешний HTTPS и маршрутизацию `/ws` обслуживает один общий `Caddy` + +Детальное описание: +- [178.208.90.249_quad_devnet.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Dev_Docs/deploy/test-server/178.208.90.249_quad_devnet.md) diff --git a/doc/instructions/coturn-install.md b/docs/instructions/coturn-install.md similarity index 100% rename from doc/instructions/coturn-install.md rename to docs/instructions/coturn-install.md diff --git a/doc/instructions/turn-connect-to-shine-server.md b/docs/instructions/turn-connect-to-shine-server.md similarity index 100% rename from doc/instructions/turn-connect-to-shine-server.md rename to docs/instructions/turn-connect-to-shine-server.md diff --git a/docs/libs/Agetn.ms b/docs/libs/Agetn.ms new file mode 100644 index 0000000..172c0e0 --- /dev/null +++ b/docs/libs/Agetn.ms @@ -0,0 +1 @@ +Данная документация местами устарела и не соответствует реальному коду \ No newline at end of file diff --git a/DOC/libs/shine-server-bd/DOC.md b/docs/libs/shine-server-bd/DOC.md similarity index 100% rename from DOC/libs/shine-server-bd/DOC.md rename to docs/libs/shine-server-bd/DOC.md diff --git a/DOC/libs/shine-server-blockchain/Doc.md b/docs/libs/shine-server-blockchain/Doc.md similarity index 100% rename from DOC/libs/shine-server-blockchain/Doc.md rename to docs/libs/shine-server-blockchain/Doc.md diff --git a/DOC/libs/shine-server-blockchain/Общая структура блока.md b/docs/libs/shine-server-blockchain/Общая структура блока.md similarity index 100% rename from DOC/libs/shine-server-blockchain/Общая структура блока.md rename to docs/libs/shine-server-blockchain/Общая структура блока.md diff --git a/DOC/libs/shine-server-blockchain/Формат существующих Body.md b/docs/libs/shine-server-blockchain/Формат существующих Body.md similarity index 100% rename from DOC/libs/shine-server-blockchain/Формат существующих Body.md rename to docs/libs/shine-server-blockchain/Формат существующих Body.md diff --git a/DOC/libs/shine-server-config/doc.md b/docs/libs/shine-server-config/doc.md similarity index 100% rename from DOC/libs/shine-server-config/doc.md rename to docs/libs/shine-server-config/doc.md diff --git a/DOC/libs/shine-server-crypto/DOC.md b/docs/libs/shine-server-crypto/DOC.md similarity index 100% rename from DOC/libs/shine-server-crypto/DOC.md rename to docs/libs/shine-server-crypto/DOC.md diff --git a/DOC/libs/shine-server-geo/DOC.md b/docs/libs/shine-server-geo/DOC.md similarity index 100% rename from DOC/libs/shine-server-geo/DOC.md rename to docs/libs/shine-server-geo/DOC.md diff --git a/DOC/libs/shine-server-log/DOC.md b/docs/libs/shine-server-log/DOC.md similarity index 100% rename from DOC/libs/shine-server-log/DOC.md rename to docs/libs/shine-server-log/DOC.md diff --git a/DOC/libs/shine-server-protocol/doc.md b/docs/libs/shine-server-protocol/doc.md similarity index 100% rename from DOC/libs/shine-server-protocol/doc.md rename to docs/libs/shine-server-protocol/doc.md diff --git a/docs/ИТХ/README.md b/docs/ИТХ/README.md new file mode 100644 index 0000000..521eee3 --- /dev/null +++ b/docs/ИТХ/README.md @@ -0,0 +1,97 @@ +# ИТХ — ежедневное закрытие блокчейна SHiNE (кратко, для людей) + +Статус: дизайн согласован, код не написан. +Подробная спецификация для реализации: [Спецификация_ИТХ_v1.md](./Спецификация_ИТХ_v1.md). + +## Что это + +Раз в сутки один из серверов SHiNE («дежурный») делает **закрытие дня**: + +1. Строит меркл-дерево по вершинам всех пользовательских цепочек — + его корень называется **ИТХ** (итоговый хэш дня). +2. Собирает **бандл** — все новые блоки, появившиеся с прошлого закрытия, + с хэшами и подписями пользователей. +3. Заливает всё это одним файлом (**чекпоинт**) в Arweave. + Каждый чекпоинт ссылается на предыдущий — получается цепочка чекпоинтов. +4. Пишет в свой Solana PDA: «крайний чекпоинт — вот такой хэш, лежит по такому Arweave-адресу». +5. Публикует пост о закрытии в своём канале закрытий прямо в Сиянии. + +Остальные серверы проверяют чекпоинт, и если всё сходится: + +- обновляют слот в **своём** PDA на этот же чекпоинт («согласен»); +- ставят **лайк** на пост дежурного в канале. + +Несогласие = просто ничего не делать (слот остаётся на прошлом чекпоинте). + +## Зачем + +- **Неизменяемость истории.** Прошлые дни зафиксированы в Arweave и Solana — + их нельзя переписать даже сговором всех живых серверов. +- **Независимая проверяемость.** Любой желающий может скачать цепочку чекпоинтов + из Arweave и проверить всю историю, не доверяя ни одному живому серверу. +- **Резерв последней надежды.** Даже если все серверы погибнут, + история до последнего чекпоинта не потеряна. + +В повседневной работе серверы синхронизируются друг с другом напрямую — +Arweave почти не читается, это гарантия, а не рабочая лошадка. + +## Кто дежурит + +График ведёт DAO в специальном PDA: упорядоченный список логинов серверов +и дата начала эпохи. Дежурный дня = `dayIndex % количество_серверов`. +Обновлять список может только DAO. + +## Расписание суток (UTC) + +| Время | Что происходит | +| --- | --- | +| День D, 00:00–24:00 | Обычная работа, блоки копятся. | +| D+1, 00:00–06:00 | Дежурный закрывает день D: Arweave → PDA → пост в канал. | +| D+1, до 24:00 | Остальные проверяют и подтверждают (PDA + лайк). | + +Если дежурный не закрыл (или закрыл неверно) — день просто пропускается: +никто ничего не подтверждает, а следующий дежурный включает в свой бандл +блоки за все пропущенные дни и ссылается на последний признанный чекпоинт. + +## Как понять, какой чекпоинт «настоящий» + +**Актуальный чекпоинт = последний, на который указывают слоты +больше половины серверов из графика.** Кворума on-chain нет — +каждый сервер публикует только своё мнение, а наблюдатель считает голоса сам. +Сервер, который систематически ошибается, DAO просто исключает из списка. + +Правило работает относительно списка: по умолчанию считают по DAO-списку, +но любой наблюдатель вправе считать голоса по **собственному списку доверенных +серверов**. Коротко: запись — по общему списку, чтение — по любому. + +## Форки и параллельные группы + +- Пользователь раздвоил свою цепочку до чекпоинта — дежурный возьмёт самую + длинную ветку, проигравшая отбросится; пользователь не наказывается. +- Ветка против уже зачекпоинченного — побеждает чекпоинт, ветка отбрасывается. +- Умудрился дописать противоречащие ветки в архивы **двух разных групп закрытий** — + цепочка замораживается навсегда (два неизменных архива слить нельзя), + дальше пользователь начинает новую цепочку `login-002`. +- Любая другая группа серверов может вести свою параллельную цепочку чекпоинтов + над теми же данными — это разрешено by design: подделать блоки они не могут, + а независимых «нотариусов» у истории становится больше. + +## Восстановление сервера с нуля + +1. Прочитать DAO PDA → список серверов графика. +2. Прочитать их PDA-слоты → последний признанный чекпоинт (>половины голосов). +3. Скачать из Arweave цепочку чекпоинтов от генезиса и раскатать бандлы. +4. «Хвост» после последнего чекпоинта добрать живой синхронизацией с серверами. + +## Кто платит + +Каждый сервер платит сам: за свои Arweave-загрузки в свои дежурные дни +и за свои Solana-транзакции. + +## Связанные документы + +- [Спецификация_ИТХ_v1.md](./Спецификация_ИТХ_v1.md) — точный формат и алгоритмы (для реализации). +- `docs/Blockchain/` — формат блоков и цепочек SHiNE. +- `docs/Blockchain/sync-between-servers.md` — живая межсерверная синхронизация. +- `docs/Solana_Architecture/README.md` — устройство Solana-части. +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` — формат `user_pda`. diff --git a/docs/ИТХ/Спецификация_ИТХ_v1.md b/docs/ИТХ/Спецификация_ИТХ_v1.md new file mode 100644 index 0000000..746a8ec --- /dev/null +++ b/docs/ИТХ/Спецификация_ИТХ_v1.md @@ -0,0 +1,398 @@ +# Спецификация ИТХ v1 — ежедневное закрытие блокчейна SHiNE + +Статус: дизайн согласован пользователем, реализации в коде нет. +Краткое описание для людей: [README.md](./README.md). + +Документ рассчитан на то, чтобы по нему можно было написать код без дополнительных +уточнений. Все байтовые порядки — big-endian (BE), все хэши — SHA-256 (32 байта), +все подписи — Ed25519, все времена — UTC. + +--- + +## 1. Термины + +| Термин | Значение | +| --- | --- | +| **День D** | Календарные сутки UTC `[D 00:00:00; D+1 00:00:00)`. | +| **dayIndex** | Порядковый номер суток от эпохи графика: `dayIndex = (началоСуток_D − epochStart) / 86400_000`. У `epochStart` dayIndex = 0. | +| **Дежурный** | Сервер, закрывающий день D: `rota.servers[dayIndex mod rota.servers.length]`. | +| **Чекпоинт** | Артефакт закрытия дня: контейнер (заголовок + state-список + бандл), одним файлом в Arweave. | +| **ИТХ** | Итоговый хэш дня = `stateRoot` — меркл-корень по вершинам всех цепочек. | +| **Бандл** | Все блоки, не покрытые предыдущим признанным чекпоинтом. | +| **checkpointHash** | SHA-256 канонического preimage заголовка (раздел 5.3). Идентификатор чекпоинта. | +| **Признанный чекпоинт** | Последний чекпоинт, на который указывают PDA-слоты больше половины серверов графика. | + +## 2. График дежурств (rota PDA, DAO) + +Отдельный PDA, создаётся и обновляется **только DAO authority** +(точная реализация — в Solana-модуле `shine-solana/shine/`, отдельным решением; +предлагаемые seeds: `["checkpoint_rota"]`). + +Логическое содержимое: + +``` +RotaV1 +- version: u8 = 1 +- epoch_start_utc_day: u32 // число суток от unix-эпохи (00:00 UTC) +- servers_count: u8 +- servers: [ login_len: u8, login: UTF-8 ] * servers_count // упорядоченный список +``` + +Правила: + +- Дежурный дня D определяется по состоянию rota PDA на момент `00:00 UTC дня D+1` + (момент начала окна закрытия). Изменение списка не пересчитывает прошлые дни. +- Вход в график — **по доверию**, не за деньги: DAO ведёт список как временный + уровень, пока не сформировано сообщество сияющих; целевое состояние — + приём/исключение серверов через голосование сияющих (см. TODO). +- При изменении состава серверов будущее распределение сдвигается автоматически — + это допустимо, календарь «дата → сервер» не ведётся. + +## 3. Расписание суток (UTC) + +| Окно | Действие | +| --- | --- | +| `D+1 00:00 – 06:00` | Дежурный строит чекпоинт дня D, заливает в Arweave, обновляет свой PDA-слот (`role=closed`), публикует пост в канале закрытий. | +| `D+1 06:00 – 24:00` | Остальные серверы проверяют; при успехе — свой PDA-слот (`role=agreed`) + лайк на пост. Проверять можно и раньше 06:00, как только чекпоинт появился. | + +- Нет валидного чекпоинта дня D к началу следующего окна закрытия → день D **пропущен**: + никто не меняет свои слоты; дежурный дня D+1 включает блоки за все непокрытые дни + и ссылается `prev`-полями на последний признанный чекпоинт. +- Сервер, отстававший несколько дней, догоняет **последовательно**: проверяет цепочку + чекпоинтов по одному (каждый — относительно предыдущего) и в конце ставит свой слот + на последний проверенный. Опоздавшее согласие допустимо в любой момент. + +## 4. Что попадает в бандл (правило среза) + +- Срез = момент начала сборки чекпоинта дежурным (внутри окна `00:00–06:00`). +- В бандл входят все блоки, известные дежурному на момент среза и **не покрытые + предыдущим признанным чекпоинтом**: для каждой цепочки `c` — блоки с номера + `prevState[c].lastNumber + 1` до локальной вершины; новые цепочки — целиком, + с первого блока (номер `0`). +- Блоки, созданные уже утром D+1 до среза, допустимо включать — это не ошибка. +- Полнота относительно других серверов **не требуется**: блоки, которых у дежурного + не было, войдут в следующий чекпоинт. Проверяющие не считают «у меня есть больше» + причиной несогласия. +- **Выбор ветки (fork-choice дежурного)**: если из вершины `prevState[c]` растут + несколько валидных веток (пользователь подписал противоречащие блоки), дежурный + берёт самую длинную валидную; при равной длине — ветку с меньшим `hash32` первого + расходящегося блока (байтовое сравнение). Ветки, не растущие из `prevState[c]`, + кандидатами не являются. Подробнее о форках — раздел 10. + +## 5. Формат чекпоинта (один файл / одна Arweave-транзакция) + +### 5.1. Контейнер + +``` +magic: 16 байт ASCII "SHiNE-CHECKPOINT" (без завершающего нуля) +headerLen: u32 BE — длина headerJson в байтах +headerJson: UTF-8 JSON (раздел 5.2) +bundle: записи бандла подряд (раздел 5.4), суммарной длиной до конца файла +``` + +Магик читаемый — открыв файл в hex-редакторе, видно, что это (по образцу +`"SQLite format 3\0"`). Версия формата в магик не входит: она в поле `v` +заголовка и в первой строке preimage (`SHiNE-CHECKPOINT-V1`). + +Рекомендуемые Arweave-теги: `App-Name: SHiNE`, `Type: checkpoint`, +`Day-Index: ` (теги информационные, доверия к ним нет — истина в подписи). + +### 5.2. headerJson + +```json +{ + "v": 1, + "dayIndex": 123, + "dayDate": "2026-07-12", + "closedBy": "serverlogin", + "closedAtMs": 1789200000000, + "serverPubKeyB58": "…", + "prevCheckpointHashHex": "…64 hex… | null", + "prevArweaveTxId": "… | null", + "stateRootHex": "…64 hex…", + "stateCount": 3500, + "bundleRootHex": "…64 hex…", + "bundleCount": 4200, + "state": [ + { "name": "alice-001", "lastNumber": 17, "lastHashHex": "…64 hex…" } + ], + "signatureB64": "…" +} +``` + +- `state` отсортирован по `name` **байтово по UTF-8, по возрастанию**, без дублей. +- `dayDate` — дата дня D (для читаемости; проверяется соответствие `dayIndex`). +- Для генезис-чекпоинта `prevCheckpointHashHex` и `prevArweaveTxId` равны `null`, + `prevState` считается пустым, бандл содержит всю историю. +- Все hex — в нижнем регистре. + +### 5.3. Канонический preimage, checkpointHash и подпись + +Preimage — UTF-8 строка из 9 строк, разделитель `\n` (без завершающего `\n`; +`null`-значения кодируются как `-`): + +``` +SHiNE-CHECKPOINT-V1 + + + + + + + + +``` + +- `checkpointHash = SHA-256(preimage)`. +- `signatureB64 = Ed25519(preimage)` актуальным клиентским ключом дежурного сервера + (ключ из его `user_pda`, действующий на момент закрытия; `serverPubKeyB58` его дублирует). +- `checkpointHash` не зависит от форматирования JSON — только от полей preimage. + +### 5.4. Запись бандла + +Записи идут подряд, отсортированы по `(name байтово, blockNumber)` по возрастанию: + +``` +nameLen: u16 BE +name: UTF-8 (blockchainName) +blockNumber: u64 BE +blockLen: u32 BE +blockBytes: полный блок как в хранилище: preimage + sigMarker + signature64 +``` + +`bundleCount` в заголовке = число записей; парсер обязан проверить, что записи +заканчиваются ровно на конце файла. + +### 5.5. Меркл-правила (общие для state и бандла) + +- Лист: `SHA-256(0x00 || данные листа)`. +- Узел: `SHA-256(0x01 || left32 || right32)`. +- При нечётном числе узлов на уровне последний **поднимается без изменения**. +- Пустое дерево → 32 нулевых байта. + +Данные листа: + +- **state** (порядок листьев = порядок `state`): + `nameBytes || 0x00 || lastNumber u64 BE || lastHash32`. + Корень = `stateRoot` = **ИТХ**. +- **бандл** (порядок листьев = порядок записей): полные байты записи из 5.4 + (включая nameLen/name/blockNumber/blockLen). Корень = `bundleRoot`. + +## 6. Слот чекпоинта в PDA сервера + +Новый типизированный блок в `user_pda` сервера (рядом с `ServerProfileBlock (30)`; +финальный номер `block_type` и правки `shine-user-pda-format-v.1.0.md` — +при реализации Solana-части, отдельным решением). Предложение: + +``` +CheckpointStateBlock (block_type = 60, block_version = 0) +- day_index: u32 +- checkpoint_hash: [32] +- arweave_tx_len: u8, arweave_tx: ASCII +- closed_by_len: u8, closed_by: UTF-8 +- my_role: u8 // 1 = closed (я закрывал), 2 = agreed (я согласен) +- updated_at_ms: u64 +``` + +Правила: + +- Слот **один**, перезаписывается на месте; истории в PDA нет + (история — цепочка чекпоинтов в Arweave и посты в канале). +- Дежурный после заливки ставит свой чекпоинт с `my_role=1`. +- Проверивший и согласный сервер ставит тот же `(day_index, checkpoint_hash, + arweave_tx, closed_by)` с `my_role=2`. +- **Несогласие = не трогать слот.** Отдельной записи «против» нет. + +Правило признания (для наблюдателей и восстановления): + +> Актуальный чекпоинт = последний (максимальный `day_index`), на который указывают +> одинаковые `(day_index, checkpoint_hash)` в слотах **больше половины** серверов +> из rota PDA. Рекомендация клиентам: до достижения половины считать актуальным +> предыдущий признанный. + +Правило признания — правило **чтения**, а не записи: + +- дефолтное множество для подсчёта — список rota PDA; +- любой наблюдатель (клиент, сторонний сервер, восстановление) вправе применять то же + правило к **собственному списку доверенных серверов**; DAO-список — рекомендация + по умолчанию (конфиг сервера: `checkpoint.trust.logins`; пусто = rota PDA); +- серверы графика при закрытии и проверке обязаны использовать общий DAO-список, + иначе ротация теряет общую точку отсчёта. Коротко: **запись — по общему списку, + чтение — по любому**. + +Параллельные группы закрытий допустимы by design: любая другая группа серверов может +вести собственную цепочку чекпоинтов над теми же данными (свой генезис, свои PDA-слоты, +свои Arweave-транзакции). Группы механически не пересекаются (разные `prev`-цепочки) +и не могут подделать содержимое блоков — только заверять историю или не включать её +часть. Запрещать это кодом сознательно не планируется; «официальность» DAO-группы +держится только на том, что её список — дефолт в клиентах. + +## 7. Канал закрытий в Сиянии + +- У каждого сервера графика — свой публичный канал `type=1`, slug `checkpoints` + (создаётся обычным `TECH_CREATE_CHANNEL`). +- При закрытии дежурный пишет в свой канал `TEXT_POST`: + + ``` + /.checkpoint + ``` + +- Согласие другого сервера — `REACTION_LIKE`, таргетирующий блок этого поста. + Отзыв согласия — `REACTION_UNLIKE`. Комментарии не требуются. +- Смысл: человекочитаемое зеркало + криптографическая проверяемость через Arweave + (пост и лайки подписаны ключами серверов, зарегистрированными в Solana), с лагом в + один день (попадут в следующий бандл). +- Машинный якорь «здесь и сейчас» — PDA-слоты; канал вторичен. +- При реализации обязательно дописать команду `/.checkpoint` в + `docs/Blockchain/02_Channel_Commands.md`. + +## 8. Алгоритм дежурного (закрытие дня D) + +1. В окне `D+1 00:00–06:00` убедиться по rota PDA, что дежурный — я. +2. Определить предыдущий признанный чекпоинт (свой слот + слоты остальных, раздел 6); + загрузить его `state` (локальный кэш или Arweave). Для генезиса — пустой. +3. Зафиксировать срез: для каждой цепочки — вершина выбранной ветки + `(lastNumber, lastHash)` по правилу fork-choice из раздела 4. +4. Собрать бандл по разделу 4, отсортировать по 5.4, посчитать `bundleRoot`. +5. Сформировать `state` по срезу, отсортировать, посчитать `stateRoot` (ИТХ). +6. Собрать preimage, `checkpointHash`, подписать серверным ключом. +7. Залить контейнер одной Arweave-транзакцией (JWK сервера с хоста, вне git), + дождаться подтверждения, получить `arweaveTxId`. +8. Обновить свой PDA-слот: `(dayIndex, checkpointHash, arweaveTxId, closedBy=я, role=1)`. +9. Опубликовать пост `/.checkpoint …` в своём канале закрытий. +10. Любая ошибка на шагах 7–9 → повторять до конца окна; не успел — день пропущен, + ничего не публиковать частично (PDA-слот и пост ставить только после успешного Arweave). + +## 9. Алгоритм проверяющего + +Вход: обнаружен новый чекпоинт дня D от сервера S (по его PDA-слоту и/или посту). + +1. **График**: `S == rota[dayIndex mod N]` по состоянию rota PDA на `00:00 D+1`; + `dayIndex` больше `day_index` последнего признанного локально чекпоинта. +2. Скачать Arweave-транзакцию из слота S; проверить `magic`, длины, парсинг. +3. Пересчитать preimage → `checkpointHash`; сверить со слотом S (и постом, если есть). +4. Проверить `signatureB64` ключом S (актуальный клиентский ключ из `user_pda` S; + допускается ключ, действовавший в день закрытия — см. TODO про ротацию). +5. `prevCheckpointHashHex`/`prevArweaveTxId` == локально признанный крайний чекпоинт. + Иначе — отказ. +6. Пересчитать `bundleRoot` по записям; сверить. Проверить сортировку и `bundleCount`. +7. **Мостик prev→state** (полнота бандла): для каждой цепочки из `state` + блоки бандла образуют непрерывную последовательность номеров и `prevHash`-ссылок + от `prevState[c]` (или от блока 0 для новой цепочки) ровно до `state[c]`. + Для каждого блока: `hash32 == SHA-256(preimage)`, структура валидна по правилам + `docs/Blockchain/01_Common_Block_Format.md`, подпись Ed25519 валидна ключом + владельца цепочки, действующим на день закрытия. +8. Пересчитать `stateRoot` по `state`; сверить (это и есть проверка ИТХ). +9. **Конфликты с локальными данными**: если локально для `(цепочка, номер)` есть блок + с другим `hash32` — побеждает версия чекпоинта: локальная ветка отбрасывается + (реорганизация), согласие НЕ отклоняется. Отказ по конфликту возможен только когда + чекпоинт противоречит уже признанному прошлому — это ловится шагом 7 (мостик не + соединится с `prevState`). «У меня есть блоки, которых нет в бандле» — тоже НЕ отказ. +10. Импортировать недостающие блоки бандла в локальное хранилище тем же валидационным + путём, что и межсерверная синхронизация (`sync-between-servers.md`). +11. Успех → обновить свой PDA-слот (`role=2`) и поставить лайк на пост S. + Отказ → не делать ничего on-chain; подробно залогировать причину + (рекомендуемый префикс лога: `CHECKPOINT_REJECT`). + +Проверка детерминирована: два честных сервера с любым набором локальных блоков приходят +к одному вердикту. Проверяется валидность выбора дежурного и стыковка с прошлым +чекпоинтом, но не «длиннейшесть» выбранной ветки — её никто, кроме дежурного, проверить +не может, и условием согласия она не является. + +## 10. Форки пользовательских цепочек + +Форк = два блока одной цепочки с одинаковым `blockNumber`, разным `hash32` и валидными +подписями владельца. Случайно-честно такое не возникает: каждый блок подписывается +владельцем и ссылается на `prevHash`. Пара таких блоков — самодостаточная +криптографическая улика, проверяемая любым сервером локально, без арбитра и голосования. + +Лесенка разруливания: + +| Ситуация | Арбитр | Исход | +| --- | --- | --- | +| Ветки разошлись до чекпоинта | Дежурный (длиннейшая + tie-break, раздел 4) | Проигравшая ветка отбрасывается, цепочка живёт | +| Локальная ветка против зачекпоинченной в своей группе | Цепочка чекпоинтов | Локальная ветка отбрасывается (шаг 9.9) | +| Чекпоинт противоречит признанному прошлому | Проверяющие | Согласий нет, день пропущен | +| Противоречие между архивами двух групп закрытий | Никто — улика | Заморозка цепочки | + +Заморозка применяется **только** в последнем случае — когда обе ветки уже зачекпоинчены +разными группами закрытий и неизменны в Arweave: слить два неизменных архива невозможно +в принципе. + +- Улика: два конфликтующих блока, каждый из которых входит в валидный чекпоинт + своей группы. +- Сервер, проверивший улику, перестаёт принимать `AddBlock` в эту цепочку + (код отказа: `chain_frozen`); улика синхронизируется партнёрам как обычные данные, + каждый проверяет и замораживает независимо. +- Уже записанная история цепочки остаётся валидной и читаемой навсегда. +- Пользователь продолжает с новой цепочкой `-` (например, `login-002`); + `BlockchainRegistryBlock` в `user_pda` уже поддерживает несколько цепочек. + Старые связи/подписки указывают на корни старой цепочки — репутационная история + не переносится, это осознанная часть последствий. + +Обязательное правило для клиентов (защищает честных пользователей от гонок): +повтор отправки блока после таймаута/ошибки — только **байт-в-байт** тем же блоком. +Байт-идентичный дубль — дедупликация, а не форк. Пересборка блока с новым `timestamp` +на тот же номер создаёт форк на ровном месте. + +## 11. Восстановление сервера с нуля + +1. Прочитать rota PDA → список серверов. +2. Прочитать их PDA-слоты → признанный чекпоинт (правило раздела 6). +3. От него по `prevArweaveTxId` спуститься до генезиса, затем раскатать бандлы + от генезиса вверх, валидируя каждый чекпоинт по разделу 9 (шаги 2–8). +4. Хвост после последнего чекпоинта добрать живой синхронизацией с серверами. + +## 12. Стоимость и ключи + +- Каждый сервер платит сам: Arweave — за свои дежурные дни, Solana — за свои слоты. +- Arweave JWK сервера хранится только на хосте (например, + `/home/player/SHiNE/secrets/…`), путь задаётся конфигом; в git не попадает — + по той же схеме, что `test.freeAvatar.walletJwkPath`. + +## 13. Ориентир для реализации на сервере (не нормативно) + +- `CheckpointScheduler` — таймер окон 00:00/06:00 UTC, определение роли по rota PDA. +- `CheckpointBuilder` — срез, бандл, меркл, preimage, подпись (разделы 4–5, 8). +- `ArweaveCheckpointUploader` — заливка контейнера, ожидание подтверждения. +- `CheckpointVerifier` — раздел 9; переиспользовать валидацию блоков из `AddBlock`/sync. +- `CheckpointSolanaSlot` — чтение/запись `CheckpointStateBlock`, чтение rota PDA. +- `CheckpointChannelPublisher` — пост `/.checkpoint` и лайки через обычный `AddBlock`. +- Конфиги (предложение): `checkpoint.enabled`, `checkpoint.arweave.jwkPath`, + `checkpoint.rota.pda` (или деривация по seeds), `checkpoint.closeWindowHours=6`. +- Локально хранить: `state` последнего признанного чекпоинта + его + `(dayIndex, hash, arweaveTxId)` — чтобы не ходить в Arweave на каждое закрытие. + +## 14. TODO / сознательно отложено + +- **История ключей при ротации**: если сервер/пользователь меняет ключ несколько раз + за день, для проверки старых подписей нужна история версий ключей. v1 живёт с + «ключ, актуальный на день закрытия»; при внедрении ротации — хранить версии. +- **Механизм новой цепочки `-002`**: пользовательский flow «продолжить с новой + цепочкой» после заморозки (регистрация в `BlockchainRegistryBlock`, UI) пока + не реализован — см. раздел 10. +- **Детерминированный выбор дежурного**: + `дежурный = SHA-256(prevCheckpointHash || dayIndex) mod N` по байтово отсортированным + логинам — убирает порядок списка как рычаг управления, DAO остаётся только + вход/выход участников. +- **Передача ведения графика сообществу**: вход в группу закрытий — по доверию, + не за деньги. Сейчас список ведёт DAO — это временный уровень, пока сообщество + сияющих не сформировано; в перспективе приём/исключение серверов — через + голосование сияющих. Депозитный/платный вход сознательно отвергнут. +- **Автопропуск штрафника** в ротации (2 подряд закрытия без единого согласия). +- **On-chain кворум** вместо правила чтения «>половины» — если понадобится. +- **Сжатие бандла** (gzip) и **чанкование** очень больших бандлов на несколько + Arweave-транзакций с манифестом. v1 — один несжатый файл. +- Экономия Solana-транзакций: считать согласия только по лайкам канала — возможно + после обкатки v1. + +## 15. Связанные документы (обновлять при реализации) + +- `docs/Blockchain/01_Common_Block_Format.md` — формат блока (используется в 5.4, 9.7). +- `docs/Blockchain/02_Channel_Commands.md` — дописать `/.checkpoint`. +- `docs/Blockchain/sync-between-servers.md` — путь импорта блоков (9.10). +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` — дописать + `CheckpointStateBlock` и rota PDA при реализации Solana-части. +- `docs/API/04_Add_Block_to_Blockchain_API.md` — при реализации дописать код отказа + `chain_frozen` (раздел 10). +- `docs/Blockchain/CHANGELOG.md` — фиксировать изменения. diff --git a/docs/Инициализация_Solana_регистрации/README.md b/docs/Инициализация_Solana_регистрации/README.md new file mode 100644 index 0000000..81eba6f --- /dev/null +++ b/docs/Инициализация_Solana_регистрации/README.md @@ -0,0 +1,103 @@ +# Деплой и инициализация Solana-регистрации + +## Коротко + +Для рабочей регистрации пользователя нужны три программы: + +1. `shine_users` — хранение и обновление `user_pda`, economy-конфиг, логика регистрации. +2. `shine_login_guard` — проверка/классификация логина через CPI из `shine_users`. +3. `shine_payments` — приём платежей за регистрацию и докупку лимита через `inflow_vault PDA`. + +Если `shine_users` или `shine_login_guard` не совпадают по адресам с UI/сервером, регистрация неработоспособна. + +## Актуальные адреса (mainnet) + +- `shine_users`: + `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` +- `shine_login_guard`: + `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` +- `shine_payments`: + `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` + +Временный on-chain admin для `shine_users` и `shine_payments` до перевода управления на DAO: + +- `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq` + +## Подтверждение текущего mainnet deploy + +- Сеть: `https://api.mainnet-beta.solana.com` +- `shine_login_guard`: + - `Program ID`: `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` + - TX deploy: `5gCxrw5gj7ig5C3HFf4AUrHq2pMW1LxgfLUj4jtmQvTqXnR7xp1iySf7m63S3MaimpeUyYhQR1zpPFJyPi6NTAv7` +- `shine_users`: + - `Program ID`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` + - TX deploy: `5Cwp53EegPv6aV69Ztxj7nXomHt9H9ZaXSgWvDfLPeR5EAaU38ADsQjn9h8xShU34AgttYGwWaVWiicF2vLwJXU9` +- `shine_payments`: + - `Program ID`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` + - TX deploy: `2vzsM85bTwSxdYvzWKZMpAg7Gqxs8R7CC5T5LTm7cRFizhr11QZvgDKqP77b8n2mcmA6DF4RqwrUrPzQ8bsQhvCn` + +## Как адреса связаны между собой + +- `shine_users` внутри кода вызывает `shine_login_guard`. +- `shine_users` внутри кода знает `shine_payments` и переводит оплату в `inflow_vault PDA` этой программы. +- `shine_users` и `shine_payments` пока управляются временным адресом `aiShm...`. +- Позже эти hardcoded authority-адреса планируется заменить на адреса DAO через upgrade программ. + +## Куда вписаны адреса в проекте + +### UI + +- `shine-UI/js/solana-programs.js` +- `shine-UI/js/pages/wallet-view.js` +- `shine-UI/js/services/solana-register-service.js` +- `shine-UI/server-ui/*.html` + +### Browser plugin wallet + +- `SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js` + +### Сервер + +- `SHiNE-server/shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java` + +### Solana / Anchor + +- `shine-solana/shine/Anchor.toml` +- `shine-solana/shine/programs/shine_users/src/lib.rs` +- `shine-solana/shine/programs/shine_users/src/settings.rs` +- `shine-solana/shine/programs/shine_login_guard/src/lib.rs` +- `shine-solana/shine/programs/shine_payments/src/lib.rs` +- `shine-solana/shine/programs/shine_payments/src/settings.rs` + +### Дополнительные потребители + +- `shine-solana/shine/programs/shine_payments/web/*.html` +- `ESP32/.../shine_homeserver_main.ino` +- `ESP32/.../shine_homeserver_ui.ino` + +## Как запустить инициализацию economy PDA + +1. Открыть UI. +2. Перейти: `Профиль -> Настройки -> Настройки разработчика -> Solana: init регистрации`. +3. Подключить mainnet-кошелёк. +4. Нажать `Запустить init_users_economy_config`. +5. Дождаться статуса `Успешно`. + +Страница вычисляет PDA `users_economy_config` по seed: + +- seed: `shine_users_economy_config` +- program: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` + +## Кто оплачивает create/update user_pda + +- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`. +- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя или сервера. +- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию. +- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`. + +## Важно + +- `init_users_economy_config` выполняется один раз на программу. Если PDA уже создан, повторный вызов вернёт ошибку `already initialized`. +- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: транзакцию оплачивает `clientKey`, а содержимое записи подписывает `rootKey`. +- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина. +- При новом devnet deploy планируется использовать те же program keypair, чтобы `program id` на devnet совпадали с mainnet. diff --git a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md b/docs/Протоколы/ESP_Pairing_и_режимы_подключения.md similarity index 84% rename from Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md rename to docs/Протоколы/ESP_Pairing_и_режимы_подключения.md index 5333464..64b77db 100644 --- a/Dev_Docs/Протоколы/ESP_Pairing_и_режимы_подключения.md +++ b/docs/Протоколы/ESP_Pairing_и_режимы_подключения.md @@ -1,6 +1,6 @@ # ESP Pairing и режимы подключения -Этот документ фиксирует текущие и новые режимы входа/подключения в SHiNE без клиентской UI-реализации. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя. +Этот документ фиксирует актуальные режимы входа/подключения в SHiNE. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя. ## 1. Текущие режимы @@ -30,7 +30,7 @@ - соединение снова входит в существующую сессию; - этот поток тоже остаётся без изменений. -## 2. Новый режим: добавление сессии через доверенное устройство пользователя +## 2. Добавление сессии через доверенное устройство пользователя Новый поток не заменяет обычный логин, а живёт рядом с ним. @@ -41,7 +41,7 @@ - реальное доверие даёт любая уже онлайн доверенная сессия пользователя; - сервер не выдаёт приватные ключи сам от себя. -Поток версии `v1`: +Текущий поток: 1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку: `UpsertEspPairingSettings` @@ -65,7 +65,7 @@ - уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены; - хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое. -## 4. Чего сервер в этой версии не делает +## 4. Чего сервер в этом режиме не делает - не передаёт приватный `clientKey`; - не расшифровывает `encryptedPayload`; @@ -73,7 +73,7 @@ - не делает клиентский UI; - не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера. -Это намеренно: серверная версия `v1` подготавливает безопасный каркас маршрутизации и состояния, а настоящая E2E-логика упаковки ключей будет жить на клиентах и ESP-устройствах. +Это намеренно: сервер остаётся безопасным каркасом маршрутизации и состояния, а E2E-логика упаковки ключей живёт на клиентах и ESP-устройствах. ## 5. Роли и ограничения diff --git a/Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md b/docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md similarity index 100% rename from Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md rename to docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md diff --git a/docs/Протоколы/Преобразование_ED25519_в_X25519.md b/docs/Протоколы/Преобразование_ED25519_в_X25519.md new file mode 100644 index 0000000..21f18db --- /dev/null +++ b/docs/Протоколы/Преобразование_ED25519_в_X25519.md @@ -0,0 +1,151 @@ +# Преобразование Ed25519 в X25519 + +## Назначение + +Этот документ фиксирует единое правило проекта SHiNE: + +- публичная идентичность пользователя задаётся одним `clientKey` в формате `Ed25519`; +- подпись пользовательских сообщений и команд выполняется ключом `Ed25519`; +- шифрование личных сообщений использует ключ `X25519`, получаемый детерминированно из того же ключевого материала; +- для преобразования используется только стандартное преобразование `Ed25519 <-> X25519`; +- самодельные формулы, альтернативные хэши и нестандартные схемы преобразования в проекте не допускаются. + +Цель документа: исключить путаницу между ключом подписи и ключом шифрования и зафиксировать один общий способ преобразования для клиента, сервера и будущих реализаций. + +## Краткое правило + +Если у пользователя есть: + +- приватный ключ `Ed25519 private`; +- публичный ключ `Ed25519 public` (`clientKey`); + +то для шифрования личных сообщений из них получаются: + +- `X25519 private` для расшифровки своих копий; +- `X25519 public` для шифрования сообщений на этого пользователя. + +При этом: + +- в профиле пользователя по-прежнему публикуется только `clientKey`; +- отдельный публичный `dmEncKey` не требуется; +- владелец устройства локально может использовать один и тот же исходный секрет и для подписи, и для вывода ключа шифрования. + +## Что считается стандартным преобразованием + +Под "стандартным преобразованием" в проекте понимается совместимое с общепринятой практикой libsodium/NaCl преобразование: + +- из `Ed25519 private` получается `X25519 private` через стандартную процедуру на основе SHA-512 и clamping; +- из `Ed25519 public` получается `X25519 public` через стандартное преобразование точки Edwards в точку Montgomery. + +Иными словами: + +- приватный ключ шифрования должен получаться из приватного ключа подписи стандартным способом; +- публичный ключ шифрования должен получаться из публичного `clientKey` стандартным способом; +- обе стороны должны получать одинаковый `X25519 public` по одному и тому же `clientKey`. + +## Преобразование приватного ключа + +Исходные данные: + +- `Ed25519 private` в SHiNE рассматривается как 32-байтный seed приватного ключа. + +Стандартная процедура: + +1. Вычислить `SHA-512(seed32)`. +2. Взять первые 32 байта результата. +3. Применить стандартный clamping для `X25519 private`. +4. Полученный результат использовать как `X25519 private`. + +Замечания: + +- нельзя использовать для `X25519 private` просто исходный 32-байтный `Ed25519 seed` без преобразования; +- нельзя заменять `SHA-512` на `SHA-256`, `HMAC`, `HKDF` или произвольную пользовательскую формулу; +- clamping обязателен. + +## Преобразование публичного ключа + +Исходные данные: + +- `clientKey` пользователя в формате `Ed25519 public` (32 байта). + +Стандартная процедура: + +1. Декодировать публичную точку Edwards из `Ed25519 public`. +2. Выполнить стандартное преобразование Edwards -> Montgomery. +3. Полученное значение использовать как `X25519 public`. + +На уровне математики используется стандартное отображение: + +- `u = (1 + y) / (1 - y)` + +где `y` берётся из публичной точки Ed25519, а результат `u` кодируется как публичный ключ `X25519`. + +Замечания: + +- нельзя получать `X25519 public` простым хэшированием `clientKey`; +- нельзя получать `X25519 public` "через приватный ключ на сервере"; +- любой клиент должен уметь вывести `X25519 public` другого пользователя, имея только его `clientKey`. + +## Что хранится в проекте + +На текущем архитектурном уровне: + +- в публичном профиле пользователя хранится только `clientKey` (`Ed25519 public`); +- приватная часть `clientKey` хранится только у владельца устройства; +- `X25519 private` и `X25519 public` считаются производными величинами и могут вычисляться по мере необходимости; +- отдельный `keyId` для DM пока не вводится. + +Это означает: + +- если в будущем пользователь сменит `clientKey`, старые DM должны будут быть перешифрованы; +- массовая перешифровка истории рассматривается как отдельная функция более позднего этапа; +- текущая архитектура должна оставлять возможность такой перешифровки, но не обязана реализовывать её прямо сейчас. + +## Как это применяется в DM + +Для личных сообщений SHiNE: + +- входящая копия сообщения шифруется на `X25519 public`, полученный из `clientKey` получателя; +- исходящая копия сообщения шифруется на `X25519 public`, полученный из `clientKey` отправителя; +- расшифровать входящую копию может только владелец приватного ключа получателя; +- расшифровать исходящую копию может только владелец приватного ключа отправителя. + +Следствия: + +- `encryptedBody` у входящей и исходящей копии одного логического сообщения обычно разный; +- сервер не должен требовать побайтного совпадения ciphertext у пары; +- сервер не должен уметь расшифровывать содержимое DM; +- подпись и шифрование используют один корневой секрет пользователя, но разные стандартно выведенные представления ключа. + +## Ограничения и компромиссы + +Эта архитектура осознанно имеет следующие свойства: + +- компрометация приватного `clientKey` одновременно даёт злоумышленнику возможность и подписывать от имени пользователя, и расшифровывать его личные сообщения; +- отдельное разделение ключа подписи и ключа шифрования пока не используется; +- простота пользовательской модели и минимизация числа публичных ключей считаются в проекте более важными, чем разделение этих ролей на текущем этапе. + +Если позже проекту понадобится более строгая модель безопасности, допускается переход на отдельный публичный ключ шифрования, но только как отдельная версия протокола. + +## Что запрещено + +В проекте запрещено: + +- придумывать собственное "детерминированное преобразование" `Ed25519 -> X25519`; +- хэшировать `clientKey` произвольным образом и считать результат `X25519 public`; +- использовать `Ed25519 public` напрямую как ключ ECDH; +- использовать сырой `Ed25519 seed` как `X25519 private` без стандартной процедуры; +- смешивать разные способы преобразования в разных клиентах. + +## Требование к реализациям + +Любая реализация SHiNE, которая: + +- подписывает DM; +- шифрует DM; +- расшифровывает DM; +- проверяет совместимость личных сообщений между устройствами; + +обязана использовать именно этот подход. + +Если библиотека уже предоставляет готовые совместимые функции преобразования `Ed25519 -> X25519`, нужно использовать их вместо самодельной реализации. diff --git a/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md b/docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md similarity index 93% rename from Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md rename to docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md index 1b0c431..5421455 100644 --- a/Dev_Docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md +++ b/docs/Протоколы/Формат_взаимодействия_внешнего_кошелька_и_ESP32.md @@ -1,6 +1,6 @@ # Формат взаимодействия внешнего кошелька и ESP32 -Этот документ фиксирует первый этап формата взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`. +Этот документ фиксирует актуальный формат взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`. Документ описывает: @@ -39,13 +39,13 @@ ESP32 возвращает: ## 2. Транспорт и маршрут -Первая версия формата использует уже существующую `wallet-session` браузерного расширения. +Текущий формат использует уже существующую `wallet-session` браузерного расширения. Схема маршрута: `browser extension -> SHiNE server -> homeserver session on ESP32 -> SHiNE server -> browser extension` -В первой версии: +В текущем формате: - отдельная цифровая подпись payload не добавляется; - отдельное E2E-шифрование для wallet RPC не добавляется; @@ -90,7 +90,7 @@ ESP32 возвращает: - `customName`; - `targetSessionName`. -Они намеренно не входят в первую версию этого запроса. +Они намеренно не входят в текущий формат этого запроса. ## 4. Формат ответа @@ -121,9 +121,9 @@ ESP32 возвращает: - `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`. - `timeMs` — время формирования ответа на стороне ESP32 в миллисекундах. -## 6. Ошибки первой версии +## 6. Ошибки текущего формата -Минимальный формат ошибки в первой версии допускается таким: +Минимальный формат ошибки допускается таким: ```json { @@ -136,7 +136,7 @@ ESP32 возвращает: } ``` -Рекомендуемые коды ошибок первой версии: +Рекомендуемые коды ошибок: - `wallet_unavailable` — на устройстве нельзя получить текущий кошелёк; - `secret_not_configured` — на устройстве ещё нет корректно сохранённого секрета; @@ -158,7 +158,7 @@ ESP32 возвращает: - если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA; - если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA; -- если `wallet.type = custom`, такой проверки по PDA в первой версии нет. +- если `wallet.type = custom`, такой проверки по PDA пока нет. При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA. @@ -308,4 +308,4 @@ ESP32: - выбор типа кошелька делается только на самом ESP32; - отдельная цифровая подпись ответа пока не используется; - отдельное E2E-шифрование wallet RPC пока не используется; -- `custom`-кошельки в первой версии не сверяются с PDA. +- `custom`-кошельки пока не сверяются с PDA. diff --git a/docs/Социальный_граф/Социальные связи сияния (правила).md b/docs/Социальный_граф/Социальные связи сияния (правила).md new file mode 100644 index 0000000..73aae3a --- /dev/null +++ b/docs/Социальный_граф/Социальные связи сияния (правила).md @@ -0,0 +1,651 @@ +# Социальные связи и социальный граф сети «Сияние» + +## 1. Общая концепция + +Социальная сеть «Сияние» формирует открытый социальный граф на основании данных, которые пользователи указывают о себе и о своих отношениях с другими пользователями. + +В социальном графе учитываются: + +- семейные связи; +- близкие друзья; +- друзья; +- статус официального аккаунта; +- статус сияющего; +- оценки, которые пользователи дают друг другу. + +Каждый пользователь самостоятельно указывает свои связи с другими людьми. Связь может существовать только с одной стороны или быть подтверждена встречной связью второго пользователя. + +Система показывает фактически заявленные пользователями отношения, не пытаясь автоматически определять, какие из них являются истинными. + +--- + +## 2. Типы аккаунтов + +Каждый аккаунт может иметь один из следующих типов: + +- не указано; +- человек; +- искусственный интеллект; +- бот; +- организация или сообщество. + +### 2.1. Человек + +Аккаунт представляет конкретного реального человека. + +Для человека дополнительно может быть указано: + +- мужчина; +- женщина; +- пол не указан. + +### 2.2. Искусственный интеллект + +Аккаунт представляет искусственный интеллект или цифровую личность, способную самостоятельно общаться и взаимодействовать с пользователями. + +### 2.3. Бот + +Аккаунт представляет автоматизированную программу или сервис. + +### 2.4. Организация или сообщество + +Аккаунт представляет организацию, компанию, проект, коллектив, сообщество, общественное объединение, инициативу или другую группу людей. + +--- + +## 3. Официальный аккаунт + +Пользователь может указать, является ли его аккаунт официальным. + +Для человека действует принцип: + +> Один реальный человек может иметь только один официальный аккаунт. + +У человека могут быть дополнительные творческие, профессиональные, тематические, анонимные или технические аккаунты, но только один аккаунт должен обозначаться как официальный аккаунт этого человека. + +### 3.1. Официальный аккаунт и голосование + +Официальный аккаунт человека предназначен для механизмов голосования, в которых действует принцип: + +> Один реальный человек — один голос. + +Именно официальный аккаунт подтверждённого реального человека может учитываться как один голос. + +Неофициальные и дополнительные аккаунты того же человека не должны давать дополнительные голоса. + +Аккаунты искусственного интеллекта, ботов, организаций и сообществ не учитываются как голоса отдельных людей. + +--- + +## 4. Подтверждение официального аккаунта + +Официальность аккаунта состоит из двух независимых элементов: + +1. владелец сам указывает, что аккаунт является официальным; +2. другие пользователи могут подтвердить его подлинность. + +При этом существуют два разных вида подтверждения: + +- подтверждение реального человека; +- подтверждение реального аккаунта организации или сообщества. + +Эти оценки имеют разное назначение и не должны смешиваться. + +--- + +## 5. Статус сияющего + +Пользователь может самостоятельно указать своё отношение к направлению «Сияние». + +Возможны следующие состояния: + +- не указано; +- сияющий; +- не интересуется духовным направлением. + +Для статуса сияющего отдельно предусмотрены пять признаков сияния. Они описаны в другом документе. + +### 5.1. Не указано + +Пользователь пока не сообщил своего отношения. + +Это состояние не означает ни «сияющий», ни «не сияющий». + +### 5.2. Сияющий + +Пользователь считает себя сияющим и может отметить соответствующие признаки сияния. + +Сияющие пользователи визуально выделяются в социальном графе кругом, свечением или другим визуальным элементом. + +### 5.3. Не интересуется духовным направлением + +Пользователь прямо указывает, что не интересуется духовным направлением сети «Сияние». + +Это не является отрицательной оценкой человека и не ограничивает его возможности в социальной сети. + +--- + +## 6. Основные виды социальных связей + +Социальные связи делятся на две категории: + +1. семейные связи; +2. дружеские связи. + +Каждая связь указывается пользователем самостоятельно. + +Связь может быть: + +- односторонней; +- подтверждённой встречной связью. + +--- + +## 7. Семейные связи + +В системе предусмотрены следующие семейные связи: + +- родитель; +- ребёнок; +- брат или сестра; +- супруг или супруга. + +Более дальние степени родства отдельно не отображаются. + +Пользователь может переходить по семейному графу от одного родственника к другому. Например, перейти к родителю своего родителя. + +--- + +## 8. Односторонние семейные связи + +Пользователь может указать семейную связь, даже если второй пользователь ещё не указал встречную связь. + +Например: + +- Анна указала Ивана как своего родителя; +- Иван пока не указал Анну как своего ребёнка. + +При просмотре графа Анны Иван отображается как её родитель. + +При просмотре графа Ивана Анна не отображается как его ребёнок, пока Иван сам не добавит эту связь. + +Неподтверждённая связь отображается пунктирной линией. + +--- + +## 9. Подтверждённые семейные связи + +Семейная связь считается подтверждённой, если существует соответствующая встречная связь. + +Например: + +- Анна указала Ивана как родителя; +- Иван указал Анну как ребёнка. + +После этого связь отображается сплошной линией. + +Для симметричных отношений применяется тот же принцип. Например, связь супругов считается подтверждённой, если оба пользователя указали соответствующую связь. + +--- + +## 10. Дружеские связи + +Существует два уровня дружеских связей: + +- друг; +- близкий друг. + +Близкий друг автоматически считается другом. + +Связь «близкий друг» является более высоким уровнем дружеской связи. + +--- + +## 11. Односторонняя дружеская связь + +Если пользователь A указал пользователя B как друга или близкого друга, но пользователь B не указал дружескую связь с A, связь является односторонней. + +При просмотре графа A пользователь B отображается в соответствии с тем типом связи, который указал A. + +При просмотре графа B пользователь A не отображается среди его друзей. + +Односторонняя связь показывается пунктирной линией. + +--- + +## 12. Подтверждённая дружеская связь + +Дружеская связь считается подтверждённой, если второй пользователь также указал дружескую связь в ответ. + +Уровень дружбы не обязан совпадать. + +Например: + +- A считает B другом; +- B считает A близким другом. + +Такая связь считается подтверждённой. + +Для подтверждения достаточно встречной связи типа «друг» или «близкий друг». + +Подтверждённая связь отображается сплошной линией. + +--- + +## 13. Отображение связи с точки зрения пользователя + +При просмотре социального графа отображается именно тот тип связи, который указал пользователь, чей профиль открыт. + +Например: + +- A считает B другом; +- B считает A близким другом. + +При просмотре графа A пользователь B отображается как друг. + +При просмотре графа B пользователь A отображается как близкий друг. + +Встречная связь влияет только на вид линии: + +- есть встречная дружеская связь — сплошная линия; +- встречной дружеской связи нет — пунктирная линия. + +--- + +## 14. Основные состояния дружеских связей + +### 14.1. Односторонний друг + +A считает B другом, но B не указал дружескую связь с A. + +Связь в графе A показывается пунктирной линией. + +### 14.2. Односторонний близкий друг + +A считает B близким другом, но B не указал дружескую связь с A. + +Связь в графе A показывается пунктирной линией. + +### 14.3. Подтверждённая обычная дружба + +A считает B другом, B считает A другом. + +Связь показывается сплошной линией. + +### 14.4. Подтверждённая дружба разного уровня + +A считает B другом, B считает A близким другом. + +При просмотре A пользователь B отображается как друг. + +При просмотре B пользователь A отображается как близкий друг. + +В обоих случаях линия сплошная. + +### 14.5. Взаимные близкие друзья + +Оба пользователя считают друг друга близкими друзьями. + +Связь отображается как подтверждённая связь близких друзей. + +--- + +## 15. Основной социальный граф + +При открытии профиля пользователь располагается в центре графа. + +Вокруг него отображаются: + +- семья; +- близкие друзья; +- друзья. + +Пользователь может переходить от одного человека к другому и продолжать движение по социальному графу. + +--- + +## 16. Отображение связей + +В социальном графе можно отдельно включать и выключать отображение следующих видов связей: + +- семья; +- близкие друзья; +- друзья. + +Можно включить один, несколько или все виды связей. + +Выбранные настройки сохраняются при переходе от одного пользователя к другому. + +--- + +## 17. Отображение сияющих + +Отдельный переключатель позволяет включить режим «Только сияющие». + +Если режим включён, среди выбранных социальных связей отображаются только сияющие пользователи. + +Если режим выключен, отображаются все пользователи. + +Сияющие пользователи дополнительно выделяются кругом или эффектом сияния. + +--- + +## 18. Оценки пользователей + +Пользователи могут ставить друг другу следующие оценки: + +- лайк; +- хорошо знаю и уверен, что человек сияющий; +- мало знаком, но видел человека сияющим; +- подтверждение реального человека; +- подтверждение реального аккаунта организации или сообщества. + +Эти оценки существуют отдельно от семейных и дружеских связей. + +--- + +## 19. Лайк + +Лайк означает общее положительное отношение к пользователю. + +Его смысл: + +> Мне нравится этот человек или аккаунт. + +Лайк не означает дружбу, родство, статус сияющего или подтверждение подлинности аккаунта. + +--- + +## 20. Сильная оценка сияния + +Пользователь может поставить оценку: + +> Я хорошо знаю этого человека и уверен, что он сияющий. + +Эта отметка предполагает хорошее знакомство с человеком и уверенность в его качествах. + +Она не обязательно означает дружбу. + +--- + +## 21. Осторожная оценка сияния + +Пользователь может поставить оценку: + +> Я мало знаком с этим человеком, но видел его сияющим. + +Эта отметка означает положительное впечатление без заявления о близком знакомстве. + +Сильные и осторожные оценки сияния учитываются отдельно. + +--- + +## 22. Подтверждение реального человека + +Эта оценка означает: + +> Я подтверждаю, что данный официальный аккаунт принадлежит реальному уникальному человеку. + +Она предназначена для учёта голосов по принципу: + +> Один реальный человек — один голос. + +Один человек должен иметь только один официальный аккаунт, который может учитываться как его голос. + +Подтверждение реального человека не означает, что пользователь является сияющим. + +--- + +## 23. Подтверждение реального аккаунта организации или сообщества + +Эта оценка означает: + +> Я подтверждаю, что это настоящий официальный аккаунт данной организации, компании, проекта или сообщества. + +Она подтверждает подлинность аккаунта организации или сообщества, но не даёт ему голос реального человека. + +Такой аккаунт может быть настоящим и подтверждённым, но не учитывается в голосованиях по принципу «один человек — один голос». + +--- + +## 24. Просмотр статистики пользователя + +При открытии профиля можно посмотреть доступную статистику: + +- количество лайков; +- количество сильных оценок сияния; +- количество осторожных оценок сияния; +- количество подтверждений реального человека; +- количество подтверждений реального аккаунта организации или сообщества; +- количество семейных связей; +- количество близких друзей; +- количество друзей; +- количество подтверждённых и неподтверждённых связей. + +Также можно открыть социальный граф пользователя и посмотреть, кого он считает членом семьи, близким другом или другом. + +--- + +## 25. Текущие ограничения статистики + +В текущей реализации нет единого рейтинга пользователей и единого центра вторичной аналитики. + +Сейчас доступны: + +- просмотр профиля; +- просмотр социальных связей; +- включение и выключение видов связей; +- фильтр «Только сияющие»; +- просмотр основных количественных показателей; +- добавление собственных оценок. + +Подробного аналитического центра, показывающего, кто именно и какие оценки ставил, пока нет. + +--- + +## 26. Предупреждение пользователю об оценках + +В интерфейсе рекомендуется показывать предупреждение: + +> В настоящий момент сеть сохраняет ваши социальные связи и оценки, но ещё не формирует единый рейтинг пользователей. В дальнейшем открытые данные могут анализироваться различными независимыми системами. Поэтому рекомендуется указывать связи и ставить оценки честно и обдуманно. + +Пользователь должен понимать, что его отметки в будущем могут учитываться при анализе: + +- подлинности аккаунтов; +- уникальности голосов; +- достоверности социального графа; +- качества подтверждений; +- его собственной репутации. + +--- + +## 27. Будущие аналитические системы + +По мере развития сети могут появляться системы, анализирующие открытые данные социального графа. + +Они смогут учитывать: + +- кто поставил оценку; +- тип аккаунта оценивающего пользователя; +- официальный ли это аккаунт; +- какие у него социальные связи; +- насколько естественно выглядит его сеть; +- не участвует ли он во взаимной накрутке; +- насколько последовательно он действует со временем. + +На основе этого могут рассчитываться: + +- показатели доверия; +- показатели подлинности; +- вероятность того, что аккаунт принадлежит уникальному человеку; +- качество подтверждений; +- признаки искусственной накрутки. + +--- + +## 28. Отсутствие единого центра аналитики + +Поскольку данные находятся в открытом блокчейне, аналитику могут предоставлять разные независимые системы. + +Одна система может учитывать взаимные связи и подтверждения родственников. + +Другая — возраст аккаунта, историю действий и независимость групп пользователей. + +Третья — использовать собственную модель доверия. + +Таким образом, ни одна система не получает исключительного права определять, кто является настоящим, честным или заслуживающим доверия пользователем. + +--- + +## 29. Честные и ложные отметки + +На начальном этапе система не обязана автоматически проверять каждую связь и оценку. + +Большинство настоящих пользователей заинтересованы указывать реальные данные, поскольку их действия связаны с публичной репутацией. + +Если пользователь намеренно: + +- указывает ложные родственные связи; +- подтверждает фиктивные аккаунты; +- создаёт несколько официальных аккаунтов; +- участвует во взаимной накрутке; +- ставит заведомо ложные оценки, + +это может быть выявлено позднее аналитическими системами. + +Системы проверки будут развиваться по мере появления реальных способов обмана. + +--- + +## 30. Теория игр и публичная ответственность + +Открытость и неизменяемость данных создают стимул действовать честно. + +Пользователь понимает, что его отметки могут быть проанализированы в будущем. + +Если он систематически подтверждает ложные аккаунты или участвует в накрутке, это может повлиять и на доверие к нему самому. + +С точки зрения теории игр для большинства пользователей долгосрочная выгода от честного поведения выше, чем выгода от очевидной и легко обнаружимой лжи. + +Неизменяемость данных не исключает обман полностью, но делает систематическую ложь более заметной и рискованной. + +--- + +## 31. Изменение данных пользователя + +Пользователь может изменять: + +- тип аккаунта; +- официальный статус; +- статус сияющего; +- признаки сияния; +- отношение к духовному направлению; +- пол; +- социальные связи. + +Например: + +- друг может стать близким другом; +- близкий друг может стать обычным другом; +- связь может быть удалена; +- может быть добавлена новая семейная связь. + +В интерфейсе отображается текущее состояние данных. + +--- + +## 32. Текущая структура профиля + +### Данные аккаунта + +- имя или название; +- фотография или изображение; +- тип аккаунта; +- пол, если указан; +- официальный или неофициальный аккаунт; +- статус сияющего; +- выбранные признаки сияния. + +### Социальные связи + +- семья; +- близкие друзья; +- друзья; +- подтверждённые связи; +- неподтверждённые связи. + +### Статистика + +- количество лайков; +- количество сильных оценок сияния; +- количество осторожных оценок сияния; +- количество подтверждений реального человека; +- количество подтверждений реального аккаунта организации или сообщества; +- другие доступные показатели. + +### Управление графом + +- включение и выключение семьи; +- включение и выключение близких друзей; +- включение и выключение друзей; +- переключатель «Только сияющие»; +- переход к другому пользователю; +- сохранение выбранных настроек отображения. + +--- + +## 33. Основные визуальные правила графа + +- пользователь, чей профиль открыт, находится в центре; +- связанные пользователи располагаются вокруг него; +- семья, близкие друзья и друзья могут отображаться на разном расстоянии; +- неподтверждённая связь показывается пунктирной линией; +- подтверждённая связь показывается сплошной линией; +- тип связи определяется мнением пользователя, чей профиль открыт; +- сияющий пользователь выделяется кругом или свечением; +- отключённые виды связей временно не отображаются; +- настройки отображения сохраняются при переходе по графу. + +--- + +## 34. Итоговые принципы + +1. Каждый пользователь самостоятельно указывает данные о себе. + +2. Каждый пользователь самостоятельно указывает свои связи с другими людьми. + +3. Социальный граф показывает связи с точки зрения пользователя, чей профиль открыт. + +4. Неподтверждённая связь отображается пунктирной линией. + +5. Подтверждённая встречной связью связь отображается сплошной линией. + +6. Для подтверждения дружбы достаточно встречной дружеской связи любого уровня. + +7. Близкий друг автоматически является другом. + +8. Семейные связи включают родителей, детей, братьев, сестёр, супругов и супруг. + +9. Более дальние родственники отдельно не отображаются, но к ним можно переходить по графу. + +10. Можно отдельно включать и выключать отображение семьи, близких друзей и друзей. + +11. Можно включить режим отображения только сияющих пользователей. + +12. Выбранные настройки сохраняются при перемещении по графу. + +13. Сияющие пользователи визуально выделяются. + +14. Лайк, две оценки сияния, подтверждение реального человека и подтверждение реального аккаунта организации или сообщества являются отдельными видами оценок. + +15. Один человек может иметь только один официальный аккаунт. + +16. Официальный аккаунт подтверждённого реального человека может учитываться как один голос. + +17. Подтверждённый аккаунт организации или сообщества является настоящим аккаунтом, но не считается голосом человека. + +18. В текущей реализации единого рейтинга нет; в будущем открытые данные смогут анализироваться разными независимыми системами. + +19. Пользователям рекомендуется ставить честные оценки, поскольку их действия могут влиять на их собственную репутацию. + +20. Системы проверки будут развиваться по мере появления реальных способов обмана и накрутки. diff --git a/predeploy/servers.md b/predeploy/servers.md deleted file mode 100644 index 3e5bbeb..0000000 --- a/predeploy/servers.md +++ /dev/null @@ -1,24 +0,0 @@ -# SHiNE Predeploy Servers - -## Access policy -- VPS-05 (`45.136.124.227`): use `player@45.136.124.227` for regular deployment operations. -- TURN shared secret (both TURN nodes): `def6d444734d380d2f67a9d345b1debf985eaba0973c343e392c060d97c30106` - -## Servers -1. `VPS-02` (legacy): `root@194.87.0.247` - - Legacy production host. - - caddy installed. - - coturn installed. -2. `VPS-05` (target): `root@45.136.124.227` - - New migration target. - - User `player` created, in `sudo`. - - SHiNE base dir: `/home/player/SHiNE`. - - TURN config path: `/etc/turnserver.conf` (coturn), work docs/scripts in `/home/player/SHiNE/coturn`. -3. `promo-node-93` (TURN node): `ubuntu@93.170.12.154` - - TURN installed: `coturn` active. - - TURN ports: `3478/tcp`, `3478/udp`, `5349/tcp`, `5349/udp`. - - TURN relay ports: `49152-50152/udp`. - - TURN config path: `/etc/turnserver.conf`. - - SHiNE dir for player: `/home/player/SHiNE/coturn`. - - Access user `player` created with sudo and SSH key copied from `ubuntu`. - - Legacy reverse SSH tunnel to `194.87.0.247:1200` was disabled (`reverse-ssh.service`). diff --git a/scripts/devnet/test_login_guard_precheck.js b/scripts/devnet/test_login_guard_precheck.js index 43838a4..050cca7 100644 --- a/scripts/devnet/test_login_guard_precheck.js +++ b/scripts/devnet/test_login_guard_precheck.js @@ -5,8 +5,8 @@ const crypto = require('crypto'); const web3 = require('@solana/web3.js'); const RPC = process.env.SOLANA_RPC || 'https://api.devnet.solana.com'; -const LOGIN_GUARD_PROGRAM_ID = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo'; -const SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P'; +const LOGIN_GUARD_PROGRAM_ID = 'SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh'; +const SIM_PAYER = 'SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh'; const login = String(process.argv[2] || '').trim().toLowerCase(); if (!login) { @@ -84,4 +84,3 @@ main().catch((e) => { console.error(e?.stack || e?.message || String(e)); process.exit(10); }); - diff --git a/scripts/install_test2_caddyfile.sh b/scripts/install_test2_caddyfile.sh index d28f505..b2d74fa 100644 --- a/scripts/install_test2_caddyfile.sh +++ b/scripts/install_test2_caddyfile.sh @@ -2,7 +2,7 @@ set -euo pipefail TARGET_HOST="${TARGET_HOST:-player@193.8.215.70}" -TARGET_DOMAIN="${TARGET_DOMAIN:-test2.shineup.me}" +TARGET_DOMAIN="${TARGET_DOMAIN:-t.shineup.me}" REMOTE_UI_DIR="${REMOTE_UI_DIR:-/home/player/SHiNE/shine-ui}" REMOTE_CADDYFILE="${REMOTE_CADDYFILE:-/etc/caddy/Caddyfile}" @@ -77,4 +77,3 @@ ssh "$TARGET_HOST" "set -euo pipefail; \ sudo chown root:root '$REMOTE_CADDYFILE'; \ sudo caddy validate --config '$REMOTE_CADDYFILE'; \ sudo systemctl restart caddy" - diff --git a/server-backup/backup-version.properties b/server-backup/backup-version.properties index c745be1..4cb102d 100644 --- a/server-backup/backup-version.properties +++ b/server-backup/backup-version.properties @@ -1,3 +1,3 @@ backup.schema.version=1 -backup.full.version=1 -last.full.backup.date=2026-06-01 +backup.full.version=2 +last.full.backup.date=2026-07-10 diff --git a/server-backup/scheme/shineup.me/captures/01_host_disk.txt b/server-backup/scheme/shineup.me/captures/01_host_disk.txt index 84f23fb..c0980e4 100644 --- a/server-backup/scheme/shineup.me/captures/01_host_disk.txt +++ b/server-backup/scheme/shineup.me/captures/01_host_disk.txt @@ -1,32 +1,17 @@ -vm85188.geo.hosting +cld9-012186 --- -Linux vm85188.geo.hosting 5.4.0-169-generic #187-Ubuntu SMP Thu Nov 23 14:52:28 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux +Linux cld9-012186 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux --- Filesystem Size Used Avail Use% Mounted on -udev 1.9G 0 1.9G 0% /dev -tmpfs 392M 1.4M 391M 1% /run -/dev/vda2 79G 6.2G 69G 9% / +tmpfs 392M 1.2M 391M 1% /run +/dev/vda2 32G 14G 17G 46% / tmpfs 2.0G 0 2.0G 0% /dev/shm tmpfs 5.0M 0 5.0M 0% /run/lock -tmpfs 2.0G 0 2.0G 0% /sys/fs/cgroup -/dev/loop0 92M 92M 0 100% /snap/lxd/24061 -/dev/loop2 41M 41M 0 100% /snap/snapd/20290 -/dev/loop4 50M 50M 0 100% /snap/snapd/26865 -/dev/loop3 92M 92M 0 100% /snap/lxd/38688 -/dev/loop5 64M 64M 0 100% /snap/core20/2769 -tmpfs 392M 0 392M 0% /run/user/1000 -tmpfs 392M 0 392M 0% /run/user/0 -/dev/loop6 64M 64M 0 100% /snap/core20/2866 +tmpfs 392M 12K 392M 1% /run/user/1002 --- -NAME FSTYPE LABEL UUID FSAVAIL FSUSE% MOUNTPOINT -loop0 squashfs 0 100% /snap/lxd/24061 -loop1 squashfs -loop2 squashfs 0 100% /snap/snapd/20290 -loop3 squashfs 0 100% /snap/lxd/38688 -loop4 squashfs 0 100% /snap/snapd/26865 -loop5 squashfs 0 100% /snap/core20/2769 -loop6 squashfs 0 100% /snap/core20/2866 -sr0 -vda -├─vda1 -└─vda2 ext4 b3a7aca2-1c7b-470b-a2ee-dc7204eba7d3 68.4G 8% / +NAME FSTYPE FSVER LABEL UUID FSAVAIL FSUSE% MOUNTPOINTS +sr0 iso9660 Joliet Extension cidata 2026-06-02-12-22-36-00 +sr1 +vda +├─vda1 +└─vda2 ext4 1.0 c422dce2-e6a3-4ce4-a9c1-17ab14e9193a 16.1G 44% / diff --git a/server-backup/scheme/shineup.me/captures/02_enabled_services.txt b/server-backup/scheme/shineup.me/captures/02_enabled_services.txt index 3254a7b..d10ea2d 100644 --- a/server-backup/scheme/shineup.me/captures/02_enabled_services.txt +++ b/server-backup/scheme/shineup.me/captures/02_enabled_services.txt @@ -1,74 +1,13 @@ -UNIT FILE STATE VENDOR PRESET -accounts-daemon.service enabled enabled -agent-memory.service enabled enabled -apparmor.service enabled enabled -atd.service enabled enabled -autovt@.service enabled enabled -blk-availability.service enabled enabled -caddy.service enabled enabled -cloud-config.service enabled enabled -cloud-final.service enabled enabled -cloud-init-local.service enabled enabled -cloud-init.service enabled enabled -console-setup.service enabled enabled -containerd.service enabled enabled -coturn.service enabled enabled -cron.service enabled enabled -dbus-org.freedesktop.ModemManager1.service enabled enabled -dbus-org.freedesktop.resolve1.service enabled enabled -dbus-org.freedesktop.thermald.service enabled enabled -dbus-org.freedesktop.timesync1.service enabled enabled -dmesg.service enabled enabled -docker.service enabled enabled -e2scrub_reap.service enabled enabled -finalrd.service enabled enabled -getty@.service enabled enabled -grub-common.service enabled enabled -grub-initrd-fallback.service enabled enabled -irqbalance.service enabled enabled -iscsi.service enabled enabled -keyboard-setup.service enabled enabled -lvm2-monitor.service enabled enabled -lxd-agent-9p.service enabled enabled -lxd-agent.service enabled enabled -ModemManager.service enabled enabled -multipath-tools.service enabled enabled -multipathd.service enabled enabled -networkd-dispatcher.service enabled enabled -networking.service enabled enabled -ondemand.service enabled enabled -open-iscsi.service enabled enabled -open-vm-tools.service enabled enabled -pollinate.service enabled enabled -rsync.service enabled enabled -rsyslog.service enabled enabled -secureboot-db.service enabled enabled -setvtrgb.service enabled enabled -shine-server.service enabled enabled -snap.lxd.activate.service enabled enabled -snapd.apparmor.service enabled enabled -snapd.autoimport.service enabled enabled -snapd.core-fixup.service enabled enabled -snapd.recovery-chooser-trigger.service enabled enabled -snapd.seeded.service enabled enabled -snapd.service enabled enabled -snapd.system-shutdown.service enabled enabled -ssh.service enabled enabled -sshd.service enabled enabled -syslog.service enabled enabled -systemd-networkd-wait-online.service enabled enabled -systemd-networkd.service enabled enabled -systemd-pstore.service enabled enabled -systemd-resolved.service enabled enabled -systemd-timesyncd.service enabled enabled -thermald.service enabled enabled -ua-reboot-cmds.service enabled enabled -ubuntu-advantage.service enabled enabled -ubuntu-fan.service enabled enabled -udisks2.service enabled enabled -ufw.service enabled enabled -unattended-upgrades.service enabled enabled -vgauth.service enabled enabled -vmtoolsd.service enabled enabled +UNIT FILE STATE PRESET +agent-memory.service enabled enabled +caddy.service enabled enabled +containerd.service enabled enabled +coturn.service enabled enabled +docker.service enabled enabled +elaira-agent.service enabled enabled +hermes-dashboard.service enabled enabled +hermes-gateway.service enabled enabled +shine-server.service enabled enabled +ubuntu-fan.service enabled enabled -71 unit files listed. +10 unit files listed. diff --git a/server-backup/scheme/shineup.me/captures/03_running_services.txt b/server-backup/scheme/shineup.me/captures/03_running_services.txt index d8f1274..bfef058 100644 --- a/server-backup/scheme/shineup.me/captures/03_running_services.txt +++ b/server-backup/scheme/shineup.me/captures/03_running_services.txt @@ -1,40 +1,35 @@ - UNIT LOAD ACTIVE SUB DESCRIPTION - accounts-daemon.service loaded active running Accounts Service - agent-memory.service loaded active running agent-memory service - atd.service loaded active running Deferred execution scheduler - caddy.service loaded active running Caddy - containerd.service loaded active running containerd container runtime - coturn.service loaded active running coTURN STUN/TURN Server + UNIT LOAD ACTIVE SUB DESCRIPTION + agent-memory.service loaded active running agent-memory service + caddy.service loaded active running Caddy + containerd.service loaded active running containerd container runtime + coturn.service loaded active running coTURN STUN/TURN Server cron.service loaded active running Regular background program processing daemon - dbus.service loaded active running D-Bus System Message Bus - docker.service loaded active running Docker Application Container Engine - getty@tty1.service loaded active running Getty on tty1 - irqbalance.service loaded active running irqbalance daemon - ModemManager.service loaded active running Modem Manager - multipathd.service loaded active running Device-Mapper Multipath Device Controller - networkd-dispatcher.service loaded active running Dispatcher daemon for systemd-networkd - polkit.service loaded active running Authorization Manager - qemu-guest-agent.service loaded active running QEMU Guest Agent - rsyslog.service loaded active running System Logging Service - shine-server.service loaded active running SHiNE Server - snapd.service loaded active running Snap Daemon - ssh.service loaded active running OpenBSD Secure Shell server - systemd-hostnamed.service loaded active running Hostname Service - systemd-journald.service loaded active running Journal Service - systemd-logind.service loaded active running Login Service - systemd-networkd.service loaded active running Network Service - systemd-resolved.service loaded active running Network Name Resolution - systemd-timesyncd.service loaded active running Network Time Synchronization - systemd-udevd.service loaded active running udev Kernel Device Manager - udisks2.service loaded active running Disk Manager - unattended-upgrades.service loaded active running Unattended Upgrades Shutdown - upower.service loaded active running Daemon for power management - user@0.service loaded active running User Manager for UID 0 - user@1000.service loaded active running User Manager for UID 1000 - uuidd.service loaded active running Daemon for generating UUIDs + dbus.service loaded active running D-Bus System Message Bus + docker.service loaded active running Docker Application Container Engine + elaira-agent.service loaded active running Elaira self-hosted agent + getty@tty1.service loaded active running Getty on tty1 + hermes-gateway.service loaded active running Hermes Agent Gateway - Messaging Platform Integration + ModemManager.service loaded active running Modem Manager + multipathd.service loaded active running Device-Mapper Multipath Device Controller + polkit.service loaded active running Authorization Manager + qemu-guest-agent.service loaded active running QEMU Guest Agent + rsyslog.service loaded active running System Logging Service + shine-server.service loaded active running SHiNE Server + ssh.service loaded active running OpenBSD Secure Shell server + systemd-hostnamed.service loaded active running Hostname Service + systemd-journald.service loaded active running Journal Service + systemd-logind.service loaded active running User Login Management + systemd-networkd.service loaded active running Network Configuration + systemd-resolved.service loaded active running Network Name Resolution + systemd-timesyncd.service loaded active running Network Time Synchronization + systemd-udevd.service loaded active running Rule-based Manager for Device Events and Files + udisks2.service loaded active running Disk Manager + unattended-upgrades.service loaded active running Unattended Upgrades Shutdown + upower.service loaded active running Daemon for power management + user@1002.service loaded active running User Manager for UID 1002 -LOAD = Reflects whether the unit definition was properly loaded. -ACTIVE = The high-level unit activation state, i.e. generalization of SUB. -SUB = The low-level unit activation state, values depend on unit type. +Legend: LOAD → Reflects whether the unit definition was properly loaded. + ACTIVE → The high-level unit activation state, i.e. generalization of SUB. + SUB → The low-level unit activation state, values depend on unit type. -33 loaded units listed. +28 loaded units listed. diff --git a/server-backup/scheme/shineup.me/captures/04_listen_ports.txt b/server-backup/scheme/shineup.me/captures/04_listen_ports.txt index f0758f2..da6b5b3 100644 --- a/server-backup/scheme/shineup.me/captures/04_listen_ports.txt +++ b/server-backup/scheme/shineup.me/captures/04_listen_ports.txt @@ -1,50 +1,32 @@ -State Recv-Q Send-Q Local Address:Port Peer Address:Port Process -LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=114218,fd=13)) -LISTEN 0 4096 0.0.0.0:2222 0.0.0.0:* users:(("docker-proxy",pid=195899,fd=4)) -LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=718,fd=13)) -LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=63620,fd=3)) -LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=99)) -LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=94)) -LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=82)) -LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=72)) -LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=67)) -LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=66)) -LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=53)) -LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=49)) -LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=32)) -LISTEN 0 1024 45.136.124.227:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=29)) -LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=23)) -LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=22980,fd=22)) -LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=102)) -LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=97)) -LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=85)) -LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=78)) -LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=79)) -LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=69)) -LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=55)) -LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=51)) -LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=35)) -LISTEN 0 1024 45.136.124.227:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=33)) -LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=27)) -LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=22980,fd=26)) -LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=195885,fd=4)) -LISTEN 0 4096 127.0.0.1:37021 0.0.0.0:* users:(("containerd",pid=188233,fd=11)) -LISTEN 0 50 *:7070 *:* users:(("java",pid=2278064,fd=9)) -LISTEN 0 4096 [::]:2222 [::]:* users:(("docker-proxy",pid=195906,fd=4)) -LISTEN 0 100 *:8080 *:* users:(("java",pid=304246,fd=11)) -LISTEN 0 4096 *:80 *:* users:(("caddy",pid=114218,fd=16)) -LISTEN 0 100 *:8018 *:* users:(("java",pid=321572,fd=13)) -LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=63620,fd=4)) -LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=38)) -LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=41)) -LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=57)) -LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=84)) -LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=88)) -LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=22980,fd=106)) -LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=40)) -LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=47)) -LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=59)) -LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=90)) -LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=91)) -LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=22980,fd=113)) -LISTEN 0 4096 *:443 *:* users:(("caddy",pid=114218,fd=12)) +State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess +LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=15)) +LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=37)) +LISTEN 0 1024 127.0.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=15)) +LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=34)) +LISTEN 0 1024 127.0.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=13)) +LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=58)) +LISTEN 0 1024 172.17.0.1:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=21)) +LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=60)) +LISTEN 0 1024 172.17.0.1:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=23)) +LISTEN 0 2048 0.0.0.0:8000 0.0.0.0:* users:(("uvicorn",pid=2056697,fd=6)) +LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=8096,fd=14)) +LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=323411,fd=17)) +LISTEN 0 4096 0.0.0.0:2222 0.0.0.0:* users:(("docker-proxy",pid=8410,fd=7)) +LISTEN 0 4096 127.0.0.1:45999 0.0.0.0:* users:(("containerd",pid=2121968,fd=15)) +LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("docker-proxy",pid=8433,fd=7)) +LISTEN 0 2048 127.0.0.1:9119 0.0.0.0:* users:(("hermes",pid=1913356,fd=7)) +LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=323588,fd=3),("systemd",pid=1,fd=130)) +LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=40)) +LISTEN 0 1024 185.229.109.118:3478 0.0.0.0:* users:(("turnserver",pid=1689866,fd=17)) +LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=56)) +LISTEN 0 1024 185.229.109.118:3479 0.0.0.0:* users:(("turnserver",pid=1689866,fd=19)) +LISTEN 0 100 *:8018 *:* users:(("java",pid=9688,fd=13)) +LISTEN 0 50 *:7070 *:* users:(("java",pid=1732508,fd=16)) +LISTEN 0 4096 [::]:2222 [::]:* users:(("docker-proxy",pid=8417,fd=7)) +LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=323588,fd=4),("systemd",pid=1,fd=132)) +LISTEN 0 4096 *:80 *:* users:(("caddy",pid=8096,fd=16)) +LISTEN 0 4096 *:443 *:* users:(("caddy",pid=8096,fd=15)) +LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=25)) +LISTEN 0 1024 [::1]:3478 [::]:* users:(("turnserver",pid=1689866,fd=62)) +LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=27)) +LISTEN 0 1024 [::1]:3479 [::]:* users:(("turnserver",pid=1689866,fd=64)) diff --git a/server-backup/scheme/shineup.me/captures/05_docker_ps.txt b/server-backup/scheme/shineup.me/captures/05_docker_ps.txt index b0ce8db..d3af29e 100644 --- a/server-backup/scheme/shineup.me/captures/05_docker_ps.txt +++ b/server-backup/scheme/shineup.me/captures/05_docker_ps.txt @@ -1,2 +1,2 @@ NAMES IMAGE STATUS PORTS -gitea gitea/gitea:1.22.6 Up 3 weeks 127.0.0.1:3000->3000/tcp, 0.0.0.0:2222->22/tcp, :::2222->22/tcp +gitea gitea/gitea:1.22.6 Up 5 weeks 127.0.0.1:3000->3000/tcp, 0.0.0.0:2222->22/tcp, [::]:2222->22/tcp diff --git a/server-backup/scheme/shineup.me/captures/06_home_player_sizes.txt b/server-backup/scheme/shineup.me/captures/06_home_player_sizes.txt index e0b4845..49f99b0 100644 --- a/server-backup/scheme/shineup.me/captures/06_home_player_sizes.txt +++ b/server-backup/scheme/shineup.me/captures/06_home_player_sizes.txt @@ -1,6 +1,8 @@ 4.0K /home/player/hosts.codex.test -16K /home/player/logs -796K /home/player/sites -15M /home/player/gitea +8.0K /home/player/Work_AGENTS +804K /home/player/sites +33M /home/player/elaira-agent 36M /home/player/agent-memory -200M /home/player/SHiNE +46M /home/player/gitea +183M /home/player/SHiNE +1.7G /home/player/hermes diff --git a/server-backup/scheme/shineup.me/captures/07_var_sizes.txt b/server-backup/scheme/shineup.me/captures/07_var_sizes.txt index f2ec913..5dc0c1f 100644 --- a/server-backup/scheme/shineup.me/captures/07_var_sizes.txt +++ b/server-backup/scheme/shineup.me/captures/07_var_sizes.txt @@ -2,11 +2,11 @@ 4.0K /var/local 4.0K /var/mail 4.0K /var/opt -28K /var/spool -60K /var/snap -76K /var/tmp -1.9M /var/backups -280M /var/cache +4.0K /var/snap +16K /var/spool +88K /var/tmp +3.2M /var/backups +143M /var/cache 1.2G /var/lib -1.2G /var/log -2.7G /var +1.5G /var/log +2.8G /var diff --git a/server-backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt b/server-backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt index 017cb46..30b0fd4 100644 --- a/server-backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt +++ b/server-backup/scheme/shineup.me/captures/UPDATED_AT_UTC.txt @@ -1 +1 @@ -2026-06-01T08:53:07Z +2026-07-10T08:27:36Z diff --git a/server-backup/scheme/shineup.me/configs/Caddyfile b/server-backup/scheme/shineup.me/configs/Caddyfile index 4be726c..b1829b0 100644 --- a/server-backup/scheme/shineup.me/configs/Caddyfile +++ b/server-backup/scheme/shineup.me/configs/Caddyfile @@ -52,3 +52,27 @@ git.shineup.me { encode zstd gzip reverse_proxy 127.0.0.1:3000 } + +test-solana-tickets.shineup.me, test-solana-tickets.shiningpeople.ru { + encode zstd gzip + root * /home/player/sites/test-solana-tickets.shineup.me + try_files {path} /index.html + file_server + header -Etag + header { + Cache-Control "no-store, no-cache, must-revalidate, max-age=0" + Pragma "no-cache" + Expires "0" + } +} + +hermes.shineup.me { + encode zstd gzip + basicauth { + player $2a$14$a41XVsBhgxgKN2MVS5Vt3Otu4C6mmv2FRo1gYDjvPDEYwYkPGnj1e + } + reverse_proxy 127.0.0.1:9119 { + header_up Host 127.0.0.1:9119 + header_up Origin http://127.0.0.1:9119 + } +} diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index af8c6cf..ccf89d4 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -15,6 +15,7 @@ import { startDebugConnectionAsInitiator, startDebugConnectionAsResponder, } from './services/call-service.js'; +import { parseDmTechBlocks } from './services/dm-tech-blocks.js'; import { authService, addAppLogEntry, @@ -27,6 +28,8 @@ import { state, terminateCurrentSession, addSignedMessageToChat, + deleteConversationMessagesBefore, + deleteSignedMessageByBaseKey, markIncomingReadByBaseKey, markOutgoingReadByBaseKey, normalizeDmChatId, @@ -47,14 +50,16 @@ import * as loginCameraView from './pages/login-camera-view.js'; import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940'; import * as loginPasswordView from './pages/login-password-view.js?v=202606201650'; import * as keyStorageView from './pages/key-storage-view.js'; +import * as publicSupportQueueView from './pages/public-support-queue-view.js'; import * as profileView from './pages/profile-view.js?v=202607150910'; import * as profileEditView from './pages/profile-edit-view.js'; -import * as walletView from './pages/wallet-view.js?v=202605300007'; +import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; import * as toolsSettingsView from './pages/tools-settings-view.js'; +import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940'; @@ -79,6 +84,18 @@ import * as addPersonalPublicChatView from './pages/add-personal-public-chat-vie import * as networkView from './pages/network-view.js'; import * as notificationsView from './pages/notifications-view.js'; +const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; +const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается'; +const DM_DECRYPT_FAILED_TEXT = 'Не удалось расшифровать сообщение'; +const DM_CORRUPTED_TEXT = 'Сообщение повреждено'; + +function dmDecryptFallbackText(error) { + const kind = authService.classifyDmDecryptFailure(error); + if (kind === 'unsupported_format') return DM_UNSUPPORTED_FORMAT_TEXT; + if (kind === 'corrupted') return DM_CORRUPTED_TEXT; + return DM_DECRYPT_FAILED_TEXT; +} + const routes = { 'start-view': startView, 'entry-settings-view': entrySettingsView, @@ -94,6 +111,7 @@ const routes = { 'login-other-device-view': loginOtherDeviceView, 'login-password-view': loginPasswordView, 'key-storage-view': keyStorageView, + queue: publicSupportQueueView, 'profile-view': profileView, 'profile-edit-view': profileEditView, 'wallet-view': walletView, @@ -101,6 +119,7 @@ const routes = { 'developer-settings-view': developerSettingsView, 'server-settings-view': serverSettingsView, 'tools-settings-view': toolsSettingsView, + 'remote-addblock-session-view': remoteAddBlockSessionView, 'device-view': deviceView, 'connect-device-view': connectDeviceView, 'device-pairing-view': clientPairingView, @@ -131,6 +150,8 @@ const toolbarEl = document.getElementById('toolbar-slot'); const appShellEl = document.querySelector('.app-shell'); const CONNECTION_CHECK_INTERVAL_MS = 20 * 1000; +const SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS = 2000; +const SIGNED_DM_DECRYPT_CONTEXT_POLL_MS = 50; const UI_VERSION_PERIODIC_CHECK_MS = 5 * 60 * 1000; const CURRENT_BUILD_HASH = String(window.__SHINE_BUILD_HASH__ || '').trim(); const UI_BUILD_HASH_PATTERN = /window\.__SHINE_BUILD_HASH__\s*=\s*'([^']+)'/; @@ -150,6 +171,8 @@ let uiUpdateReloadScheduled = false; let pwaUpdateCheckAttempted = false; let uiVersionCheckInFlight = false; let uiVersionPeriodicIntervalId = null; +let hiddenDmAudioContext = null; +let hiddenDmAudioUnlocked = false; const CALL_PUSH_PENDING_ACTION_KEY = 'shine-ui-call-push-pending-action-v1'; const GUEST_ALLOWED_PAGES = new Set([ 'start-view', @@ -160,6 +183,7 @@ const GUEST_ALLOWED_PAGES = new Set([ 'channel-thread-view', 'user', 'contact-search-view', + 'queue', ]); setClientErrorTransport((payload) => authService.reportClientUiError(payload)); @@ -172,6 +196,94 @@ initPwaInstallPromptHandling(); initCallUiOverlay(); setCallDebugReporter((payload) => authService.reportClientDebug(payload)); +async function unlockHiddenDmAudio() { + try { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (!Ctx) return false; + if (!hiddenDmAudioContext) { + hiddenDmAudioContext = new Ctx(); + } + if (hiddenDmAudioContext.state === 'suspended') { + await hiddenDmAudioContext.resume(); + } + hiddenDmAudioUnlocked = hiddenDmAudioContext.state === 'running'; + return hiddenDmAudioUnlocked; + } catch { + return false; + } +} + +async function playDmSignal({ extended = false } = {}) { + try { + if (!hiddenDmAudioUnlocked || !hiddenDmAudioContext) return false; + if (hiddenDmAudioContext.state === 'suspended') { + await hiddenDmAudioContext.resume(); + } + if (hiddenDmAudioContext.state !== 'running') return false; + + const now = hiddenDmAudioContext.currentTime; + const gain = hiddenDmAudioContext.createGain(); + gain.connect(hiddenDmAudioContext.destination); + gain.gain.setValueAtTime(0.0001, now); + + const pulse = (offsetSec, freqHz, durationSec, peakGain) => { + const osc = hiddenDmAudioContext.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freqHz, now + offsetSec); + osc.connect(gain); + gain.gain.exponentialRampToValueAtTime(peakGain, now + offsetSec + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + offsetSec + durationSec); + osc.start(now + offsetSec); + osc.stop(now + offsetSec + durationSec + 0.02); + }; + + if (extended) { + pulse(0, 880, 0.18, 0.032); + pulse(0.24, 1174, 0.2, 0.026); + pulse(0.52, 1567, 0.24, 0.02); + } else { + pulse(0, 1046, 0.12, 0.028); + pulse(0.17, 1318, 0.14, 0.022); + } + return true; + } catch { + return false; + } +} + +async function notifyHiddenIncomingMessage(fromLogin, text) { + const body = String(text || '').trim() || `Вам пришло сообщение от ${fromLogin}`; + const title = `Сообщение от ${fromLogin}`; + try { + const registration = await navigator.serviceWorker?.getRegistration?.(); + if (registration?.showNotification) { + await registration.showNotification(title, { + body, + tag: `shine-hidden-dm-${String(fromLogin || '').trim().toLowerCase() || 'unknown'}`, + renotify: true, + data: { + kind: 'new_message', + fromLogin, + }, + }); + } else { + new Notification(title, { body }); + } + } catch { + // ignore notification errors + } + + try { + if (typeof navigator.vibrate === 'function') { + navigator.vibrate([140, 80, 220, 90, 180]); + } + } catch { + // ignore vibration errors + } + + void playDmSignal({ extended: true }); +} + function ensureConnectionIndicatorEl() { return document.getElementById('toolbar-connection-indicator'); } @@ -555,6 +667,44 @@ function wsIsOpen() { return !!(ws && ws.readyState === WebSocket.OPEN); } +function sleep(ms) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +async function waitForSignedDmDecryptContext(timeoutMs = SIGNED_DM_DECRYPT_CONTEXT_WAIT_MS) { + const startedAtMs = Date.now(); + while ((Date.now() - startedAtMs) < timeoutMs) { + const login = String(state.session?.login || '').trim(); + const storagePwd = String(state.session?.storagePwdInMemory || '').trim(); + if (login && storagePwd) { + return { login, storagePwd }; + } + await sleep(SIGNED_DM_DECRYPT_CONTEXT_POLL_MS); + } + return { + login: String(state.session?.login || '').trim(), + storagePwd: String(state.session?.storagePwdInMemory || '').trim(), + }; +} + +function resolveSignedDmChatId({ parsed, login }) { + const parsedBlock = parsed || {}; + const messageType = Number(parsedBlock.messageType || 0); + const fromLogin = String(parsedBlock.fromLogin || '').trim(); + const toLogin = String(parsedBlock.toLogin || '').trim(); + const currentLogin = String(login || '').trim().toLowerCase(); + + if (messageType === 1) return normalizeDmChatId(fromLogin); + if (messageType === 2) return normalizeDmChatId(toLogin); + + if (currentLogin && currentLogin === fromLogin.toLowerCase()) { + return normalizeDmChatId(toLogin); + } + return normalizeDmChatId(fromLogin); +} + async function ensureSessionAfterWsReconnect() { if (!state.session.isAuthorized) return true; if (wsSessionRestoreInFlight) return wsSessionRestoreInFlight; @@ -754,6 +904,28 @@ function renderApp() { } } +function refreshToolbarOnly() { + const route = getRoute(); + const pageId = route.pageId || (state.session.isAuthorized ? 'messages-list' : 'start-view'); + const page = routes[pageId] || routes['start-view']; + const showAppChrome = page.pageMeta?.showAppChrome !== false; + + toolbarEl.innerHTML = ''; + if (showAppChrome) { + toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); + } + refreshConnectionUi(); +} + +function refreshUnreadUi() { + const pageId = getRoute().pageId || ''; + if (pageId === 'messages-list') { + renderApp(); + return; + } + refreshToolbarOnly(); +} + async function tryAutoLogin() { if (state.session.isLocalDemo) return; if (!state.session.login || !state.session.sessionId) return; @@ -928,10 +1100,48 @@ async function init() { const fromLogin = parsed.fromLogin || ''; const toLogin = parsed.toLogin || ''; const messageType = Number(parsed.messageType || 0); - const chatId = normalizeDmChatId(messageType === 2 ? toLogin : fromLogin); - const text = (messageType === 1 || messageType === 2) - ? String(parsed.text || '') - : ''; + let shouldAckSessionDelivery = true; + let text = ''; + let sessionLoginForRouting = String(state.session?.login || '').trim(); + if (messageType === 1 || messageType === 2) { + const decryptContext = await waitForSignedDmDecryptContext(); + if (!decryptContext.login || !decryptContext.storagePwd) { + shouldAckSessionDelivery = false; + addAppLogEntry({ + level: 'warn', + source: 'signed-dm', + message: 'Отложена обработка DM: decrypt-контекст сессии ещё не готов', + details: { messageKey, baseKey: parsed.baseKey, messageType }, + }); + return; + } + sessionLoginForRouting = decryptContext.login; + try { + const decrypted = await authService.decryptSignedMessageContent({ + parsed, + login: decryptContext.login, + storagePwd: decryptContext.storagePwd, + }); + text = String(decrypted?.text || ''); + } catch (error) { + text = dmDecryptFallbackText(error); + addAppLogEntry({ + level: 'warn', + source: 'signed-dm', + message: 'Не удалось расшифровать DM', + details: { + messageKey, + baseKey: parsed.baseKey, + error: error?.message || 'unknown', + fallbackText: text, + }, + }); + } + } + const chatId = resolveSignedDmChatId({ + parsed, + login: sessionLoginForRouting, + }); let shouldRefreshToolbarUnread = false; @@ -962,16 +1172,24 @@ async function init() { if (added && isIncomingForCurrent) { shouldRefreshToolbarUnread = true; } - if (added && isIncomingForCurrent && Notification.permission === 'granted' && !payload.backlog) { - try { - new Notification(`Сообщение от ${fromLogin}`, { body: text || '' }); - } catch {} + if (added && isIncomingForCurrent && !payload.backlog) { + const parsedText = parseDmTechBlocks(text); + const displayText = String(parsedText.displayText || ''); + if (document.visibilityState === 'visible') { + void playDmSignal({ extended: false }); + } else if (Notification.permission === 'granted') { + try { + void notifyHiddenIncomingMessage(fromLogin, displayText || ''); + } catch {} + } else { + void playDmSignal({ extended: true }); + } } } else if (messageType === 3 || messageType === 4) { let refBaseKey = String(payload.receiptRefBaseKey || '').trim(); if (!refBaseKey) { try { - const ref = authService.parseReadReceiptPayload(parsed.payloadBytes); + const ref = parsed.readReceiptPayload || authService.parseReadReceiptPayload(parsed.payloadBytes); refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`; } catch {} } @@ -979,7 +1197,9 @@ async function init() { if (messageType === 3) { markOutgoingReadByBaseKey(refBaseKey); } else { - markIncomingReadByBaseKey(refBaseKey); + if (markIncomingReadByBaseKey(refBaseKey)) { + shouldRefreshToolbarUnread = true; + } } } addAppLogEntry({ @@ -988,21 +1208,62 @@ async function init() { message: 'Получено подтверждение прочтения', details: { messageKey, baseKey: parsed.baseKey, messageType }, }); - } - - try { - await authService.ackSessionDelivery(messageKey); - } catch (error) { + } else if (messageType === 5 || messageType === 6) { + const changed = deleteSignedMessageByBaseKey(chatId, parsed.baseKey); + if (changed) shouldRefreshToolbarUnread = true; addAppLogEntry({ - level: 'warn', + level: 'info', source: 'signed-dm', - message: 'Не удалось отправить ACK доставки по сессии', - details: { messageKey, error: error?.message || 'unknown' }, + message: 'Получено удаление сообщения', + details: { messageKey, baseKey: parsed.baseKey, messageType, changed }, + }); + } else if (messageType === 7 || messageType === 8) { + const removed = deleteConversationMessagesBefore(chatId, Number(parsed.timeMs || 0)); + addSignedMessageToChat({ + chatId, + messageKey, + baseKey: parsed.baseKey, + from: messageType === 8 ? 'out' : 'in', + text: CONVERSATION_CLEAR_NOTICE_TEXT, + messageType, + unread: false, + rawBlobB64: blobB64, + }); + if (removed > 0) shouldRefreshToolbarUnread = true; + addAppLogEntry({ + level: 'info', + source: 'signed-dm', + message: 'Получено удаление истории переписки', + details: { messageKey, baseKey: parsed.baseKey, messageType, removed, boundaryTimeMs: Number(parsed.timeMs || 0) }, }); } + if (shouldAckSessionDelivery) { + try { + await authService.ackSessionDelivery(messageKey); + } catch (error) { + addAppLogEntry({ + level: 'warn', + source: 'signed-dm', + message: 'Не удалось отправить ACK доставки по сессии', + details: { messageKey, error: error?.message || 'unknown' }, + }); + } + } + const pageId = getRoute().pageId || ''; - if (pageId === 'chat-view' || pageId === 'messages-list' || shouldRefreshToolbarUnread) { + if (pageId === 'chat-view') { + window.dispatchEvent(new CustomEvent('shine-chat-messages-updated', { + detail: { + chatId, + messageType, + messageKey, + }, + })); + if (shouldRefreshToolbarUnread) { + refreshToolbarOnly(); + } + } else if (pageId === 'messages-list' || shouldRefreshToolbarUnread) { renderApp(); } }); @@ -1011,6 +1272,8 @@ async function init() { try { await handleIncomingCallInvite(evt); } catch {} }); + window.addEventListener('shine-unread-state-updated', refreshUnreadUi); + authService.onEvent('IncomingCallSignal', async (evt) => { try { await handleIncomingCallSignal(evt); } catch {} }); @@ -1099,6 +1362,12 @@ async function init() { })(); window.addEventListener('popstate', renderApp); + document.addEventListener('pointerdown', () => { + void unlockHiddenDmAudio(); + }, { passive: true }); + document.addEventListener('keydown', () => { + void unlockHiddenDmAudio(); + }, { passive: true }); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; void checkConnectionHealth(); diff --git a/shine-UI/js/components/header.js b/shine-UI/js/components/header.js index 7a5b68e..ead1a75 100644 --- a/shine-UI/js/components/header.js +++ b/shine-UI/js/components/header.js @@ -26,8 +26,10 @@ export function renderHeader({ title, leftAction, leftLabel = '', rightActions = right.className = 'header-actions'; rightActions.forEach((action) => { const btn = document.createElement('button'); - btn.className = 'icon-btn'; + btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`; btn.textContent = action.label; + if (action.title) btn.title = action.title; + if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel); btn.addEventListener('click', action.onClick); right.append(btn); }); diff --git a/shine-UI/js/deploy-config.js b/shine-UI/js/deploy-config.js index 4fa5068..cb6a5c3 100644 --- a/shine-UI/js/deploy-config.js +++ b/shine-UI/js/deploy-config.js @@ -5,3 +5,5 @@ export const defaultServerLogin = 'shineupme'; export const defaultServerAddress = 'shineup.me'; export const defaultServerHttp = `https://${defaultServerAddress}`; export const defaultServerWs = `wss://${defaultServerAddress}/ws`; +export const defaultSolanaCluster = 'mainnet-beta'; +export const defaultSolanaEndpoint = 'https://solana-rpc.publicnode.com'; diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index dffd7a3..6b56ea1 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -6,6 +6,7 @@ import { addSignedMessageToChat, addSystemChatMessage, addOutgoingPendingMessage, + deleteConversationMessagesBefore, getChatMessages, markChatRead, markOutgoingSent, @@ -23,10 +24,13 @@ import { isTelegramAnimatedEmoji, stopAllTelegramEmojiAnimations, } from '../components/emoji-picker.js?v=202607152130'; -import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; +import { openSpeechInputModal } from '../components/speech-input-modal.js'; +import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { showToast } from '../services/channels-ux.js'; +import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; +const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; function truncatePreviewText(value, maxLen = 72) { const normalized = String(value || '').replace(/\s+/g, ' ').trim(); @@ -35,6 +39,12 @@ function truncatePreviewText(value, maxLen = 72) { return `${normalized.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`; } +function findMessageByBaseKey(messages, baseKey) { + const normalized = String(baseKey || '').trim(); + if (!normalized) return null; + return messages.find((row) => String(row?.baseKey || '').trim() === normalized) || null; +} + function openDeleteMessageConfirmModal({ onConfirm }) { const root = document.getElementById('modal-root'); if (!root) return; @@ -62,12 +72,83 @@ function openDeleteMessageConfirmModal({ onConfirm }) { }); } +function openChatConfirmModal({ + title = 'Подтвердить действие', + text = '', + confirmLabel = 'Да', + confirmKind = 'primary', + onConfirm, +}) { + const root = document.getElementById('modal-root'); + if (!root) return; + const confirmClass = confirmKind === 'danger' ? 'destructive-btn' : 'primary-btn'; + root.innerHTML = ` + + `; + + const close = () => { + root.innerHTML = ''; + }; + + root.querySelector('#chat-confirm-no')?.addEventListener('click', close); + root.querySelector('#chat-confirm-yes')?.addEventListener('click', async () => { + close(); + if (typeof onConfirm === 'function') await onConfirm(); + }); +} + +function openDeleteChatConfirmModal({ contactName = '', onConfirm }) { + const root = document.getElementById('modal-root'); + if (!root) return; + root.innerHTML = ` + + `; + + const close = () => { + root.innerHTML = ''; + }; + + root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close); + root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => { + const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked); + close(); + if (typeof onConfirm === 'function') { + await onConfirm({ deleteHistory }); + } + }); +} + function openMessageActionsMenu({ anchorX = 0, anchorY = 0, messageText = '', + canReply = false, + showReadAloud = true, canEdit = false, canDelete = false, + onReply, onReadAloud, onEdit, onDelete, @@ -79,8 +160,9 @@ function openMessageActionsMenu({ root.innerHTML = `
@@ -137,6 +219,11 @@ function openMessageActionsMenu({ } }); + root.querySelector('#msg-action-reply')?.addEventListener('click', async () => { + close(); + if (typeof onReply === 'function') await onReply(); + }); + root.querySelector('#msg-action-read')?.addEventListener('click', async () => { close(); if (typeof onReadAloud === 'function') await onReadAloud(); @@ -153,6 +240,78 @@ function openMessageActionsMenu({ }); } +function openChatActionsMenu({ + anchorX = 0, + anchorY = 0, + onCall, + onClearHistory, + onDeleteChat, +}) { + const root = document.getElementById('modal-root'); + if (!root) return; + + const menuId = `chat-header-actions-menu-${Date.now()}`; + root.innerHTML = ` +
+ +
+ `; + + const menu = root.querySelector(`#${menuId}`); + if (!menu) return; + + const close = () => { + document.removeEventListener('pointerdown', onDocumentPointerDown, true); + window.removeEventListener('resize', close); + window.removeEventListener('scroll', close, true); + root.innerHTML = ''; + }; + + const onDocumentPointerDown = (event) => { + if (menu.contains(event.target)) return; + close(); + }; + + document.addEventListener('pointerdown', onDocumentPointerDown, true); + window.addEventListener('resize', close); + window.addEventListener('scroll', close, true); + + window.requestAnimationFrame(() => { + const menuRect = menu.getBoundingClientRect(); + const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); + const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); + const left = Math.min( + Math.max(12, Number(anchorX || 0) - menuRect.width + 8), + Math.max(12, viewportWidth - menuRect.width - 12) + ); + const top = Math.min( + Math.max(12, Number(anchorY || 0) + 10), + Math.max(12, viewportHeight - menuRect.height - 12) + ); + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`; + menu.classList.add('is-visible'); + }); + + root.querySelector('#chat-menu-call')?.addEventListener('click', async () => { + close(); + if (typeof onCall === 'function') await onCall(); + }); + root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => { + close(); + if (typeof onClearHistory === 'function') await onClearHistory(); + }); + root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => { + close(); + if (typeof onDeleteChat === 'function') await onDeleteChat(); + }); +} + function showTtsMissingConfigDialog(navigate) { const root = document.getElementById('modal-root'); if (!root) return; @@ -254,6 +413,31 @@ function formatMessageTime(valueMs) { }).format(new Date(timeMs)); } +function formatCallDurationCompact(totalSeconds) { + const total = Math.max(0, Math.floor(Number(totalSeconds || 0))); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function buildCallStatusText(callSummary) { + if (!callSummary) return ''; + if (callSummary.status === 'completed') { + return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`; + } + const reason = String(callSummary.reason || '').trim().toLowerCase(); + if (reason === 'offline') return 'Не дозвонился: абонент не в сети'; + if (reason === 'no_answer') return 'Не дозвонился: нет ответа'; + if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение'; + if (reason === 'busy') return 'Не дозвонился: абонент занят'; + if (reason === 'declined') return 'Не дозвонился: звонок отклонён'; + return 'Не дозвонился'; +} + function resolveDeliveryStatus(msg) { if (msg?.from !== 'out') return ''; if (msg?.secondTick) return '✓✓'; @@ -269,8 +453,13 @@ function resolveMessageEditedTimeMs(msg) { function scrollToLatestMessage(list) { if (!list) return; + const scrollContainer = list.closest('.screen-content') || list.parentElement || list; + const lastBubble = list.lastElementChild; const apply = () => { - list.scrollTop = list.scrollHeight; + if (lastBubble?.scrollIntoView) { + lastBubble.scrollIntoView({ block: 'end', inline: 'nearest' }); + } + scrollContainer.scrollTop = scrollContainer.scrollHeight; }; apply(); window.requestAnimationFrame(apply); @@ -294,7 +483,51 @@ function renderMessageText(textNode, messageText) { } } -function renderLog(list, chatId, { onOpenActions } = {}) { +function scrollToLatestMessageSmart(list, { smoothIfNearBottom = false } = {}) { + if (!list) return; + const scrollContainer = list.closest('.screen-content') || list.parentElement || list; + const lastBubble = list.lastElementChild; + const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); + const distanceToBottom = Math.max(0, maxScrollTop - scrollContainer.scrollTop); + const useSmooth = smoothIfNearBottom && distanceToBottom <= 180; + + const apply = (behavior = useSmooth ? 'smooth' : 'auto') => { + if (lastBubble?.scrollIntoView) { + lastBubble.scrollIntoView({ block: 'end', inline: 'nearest', behavior }); + } + if (behavior === 'smooth' && typeof scrollContainer.scrollTo === 'function') { + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' }); + } else { + scrollContainer.scrollTop = scrollContainer.scrollHeight; + } + }; + + apply(useSmooth ? 'smooth' : 'auto'); + window.requestAnimationFrame(() => apply('auto')); + window.setTimeout(() => apply('auto'), useSmooth ? 220 : 90); +} + +function scrollToUnreadSeparator(list) { + if (!list) return false; + const separator = list.querySelector('.chat-unread-separator'); + if (!separator) return false; + const scrollContainer = list.closest('.screen-content') || list.parentElement || list; + const apply = () => { + if (separator?.scrollIntoView) { + separator.scrollIntoView({ block: 'start', inline: 'nearest' }); + } + const bottomSlack = 72; + scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - bottomSlack); + }; + apply(); + window.requestAnimationFrame(apply); + window.requestAnimationFrame(() => window.requestAnimationFrame(apply)); + window.setTimeout(apply, 60); + window.setTimeout(apply, 160); + return true; +} + +function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) { list.innerHTML = ''; const messages = getChatMessages(chatId); let unreadSeparatorInserted = false; @@ -310,13 +543,70 @@ function renderLog(list, chatId, { onOpenActions } = {}) { } const bubble = document.createElement('div'); - const bubbleKind = String(msg?.kind || '').trim(); + const parsedText = parseDmTechBlocks(String(msg?.text || '')); + const autoKind = parsedText.callSummary ? 'call-tech' : ''; + const bubbleKind = String(msg?.kind || autoKind || '').trim(); bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; + if (parsedText.callSummary) { + const callCard = document.createElement('div'); + callCard.className = 'bubble-call-card'; + + const callHead = document.createElement('div'); + callHead.className = 'bubble-call-head'; + + const callIcon = document.createElement('span'); + callIcon.className = 'bubble-call-icon'; + callIcon.textContent = '☎'; + + const callTitle = document.createElement('span'); + callTitle.className = 'bubble-call-title'; + callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок'; + + callHead.append(callIcon, callTitle); + + const callMetaLine = document.createElement('div'); + callMetaLine.className = 'bubble-call-line bubble-call-line--muted'; + const callStatus = buildCallStatusText(parsedText.callSummary); + callMetaLine.textContent = callStatus; + + callCard.append(callHead, callMetaLine); + bubble.append(callCard); + } + const textNode = document.createElement('div'); textNode.className = 'bubble-text'; - renderMessageText(textNode, msg.text); - bubble.append(textNode); + renderMessageText(textNode, parsedText.displayText || ''); + if (parsedText.replyRef) { + const replyTarget = findMessageByBaseKey(messages, parsedText.replyRef.baseKey); + if (replyTarget) { + const replyParsed = parseDmTechBlocks(String(replyTarget?.text || '')); + const replyBox = document.createElement('button'); + replyBox.type = 'button'; + replyBox.className = 'bubble-reply-preview'; + + const replyAuthor = document.createElement('div'); + replyAuthor.className = 'bubble-reply-preview-author'; + replyAuthor.textContent = String(replyTarget?.from === 'out' ? state.session.login : parsedText.replyRef.fromLogin); + + const replyText = document.createElement('div'); + replyText.className = 'bubble-reply-preview-text'; + replyText.textContent = truncatePreviewText(replyParsed.displayText || '', 96); + + replyBox.append(replyAuthor, replyText); + replyBox.addEventListener('click', (event) => { + event.stopPropagation(); + const targetNode = list.querySelector(`[data-base-key="${CSS.escape(String(replyTarget?.baseKey || ''))}"]`); + if (targetNode?.scrollIntoView) { + targetNode.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); + } + }); + bubble.append(replyBox); + } + } + if (!parsedText.callSummary) { + bubble.append(textNode); + } const metaNode = document.createElement('div'); metaNode.className = 'bubble-meta'; @@ -347,10 +637,46 @@ function renderLog(list, chatId, { onOpenActions } = {}) { bubble.addEventListener('click', (event) => { if (typeof onOpenActions === 'function') onOpenActions(msg, event); }); + bubble.dataset.baseKey = String(msg?.baseKey || ''); list.append(bubble); }); - scrollToLatestMessage(list); - markChatRead(chatId); + if (scrollMode === 'unread' && !scrollToUnreadSeparator(list)) { + scrollToLatestMessage(list); + } else if (scrollMode === 'latest') { + scrollToLatestMessage(list); + } + if (markAsRead) { + markChatRead(chatId); + } +} + +function preserveComposerSelection(input, callback) { + if (!input || typeof callback !== 'function') { + if (typeof callback === 'function') callback(); + return; + } + + const wasFocused = document.activeElement === input; + const start = Number(input.selectionStart ?? input.value.length); + const end = Number(input.selectionEnd ?? input.value.length); + + callback(); + + if (!wasFocused) return; + try { + input.focus({ preventScroll: true }); + } catch { + input.focus(); + } + try { + input.setSelectionRange(start, end); + } catch { + // ignore + } +} + +function setChatKeyboardOpen(isOpen) { + document.body.classList.toggle('chat-keyboard-open', !!isOpen); } export function render({ navigate, route }) { @@ -364,14 +690,58 @@ export function render({ navigate, route }) { const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-chat-screen'; + const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings); + const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings); const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); + const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread); const handleReadAloud = async (msg) => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } - await speakTextBySettings(String(msg?.text || ''), state.entrySettings); + const parsedText = parseDmTechBlocks(String(msg?.text || '')); + await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings); + }; + + const handleStartCall = async () => { + try { + await startOutgoingCall(chatId); + renderLog(log, chatId, { onOpenActions: handleOpenActions }); + } catch (e) { + addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { + from: 'out', + kind: 'call-tech', + }); + renderLog(log, chatId, { onOpenActions: handleOpenActions }); + } + }; + + const clearConversationHistory = async () => { + const result = await authService.deleteConversation({ + login: state.session.login, + toLogin: chatId, + storagePwd: state.session.storagePwdInMemory, + deleteByRecipient: true, + }); + const parsed = authService.parseSignedMessageBlob(String(result?.localBlobB64 || '').trim()); + deleteConversationMessagesBefore(chatId, Number(parsed?.timeMs || 0)); + addSignedMessageToChat({ + chatId, + messageKey: String(parsed?.messageKey || ''), + baseKey: String(parsed?.baseKey || ''), + from: 'out', + text: CONVERSATION_CLEAR_NOTICE_TEXT, + messageType: Number(parsed?.messageType || 8), + unread: false, + rawBlobB64: String(result?.localBlobB64 || ''), + }); + renderLog(log, chatId, { + onOpenActions: handleOpenActions, + markAsRead: false, + scrollMode: 'latest', + }); + notifyUnreadStateUpdated(); }; const wrap = document.createElement('div'); @@ -384,21 +754,70 @@ export function render({ navigate, route }) { renderHeader({ title: `Чат с ${contact.name}`, leftAction: { label: '←', onClick: () => navigate('messages-list') }, - rightActions: [{ - label: 'Позвонить', - onClick: async () => { - try { - await startOutgoingCall(chatId); - renderLog(log, chatId, { onOpenActions: handleOpenActions }); - } catch (e) { - addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { - from: 'out', - kind: 'call-tech', - }); - renderLog(log, chatId, { onOpenActions: handleOpenActions }); - } + rightActions: [ + { + label: '☎', + title: 'Позвонить', + ariaLabel: 'Позвонить', + className: 'chat-header-icon-btn chat-header-call-btn', + onClick: handleStartCall, }, - }], + { + label: '⋯', + title: 'Действия чата', + ariaLabel: 'Открыть меню действий чата', + className: 'chat-header-icon-btn chat-header-menu-btn', + onClick: (event) => { + openChatActionsMenu({ + anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0), + anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0), + onCall: handleStartCall, + onClearHistory: async () => { + openChatConfirmModal({ + title: 'Очистить историю?', + text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`, + confirmLabel: 'Очистить', + confirmKind: 'danger', + onConfirm: async () => { + try { + await clearConversationHistory(); + showToast('История чата очищена', { timeoutMs: 1200 }); + } catch (error) { + showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 }); + } + }, + }); + }, + onDeleteChat: async () => { + openDeleteChatConfirmModal({ + contactName: contact.name, + onConfirm: async ({ deleteHistory }) => { + try { + if (deleteHistory) { + await clearConversationHistory(); + } + await authService.setUserRelation({ + login: state.session.login, + toLogin: chatId, + kind: 'contact', + enabled: false, + storagePwd: state.session.storagePwdInMemory, + }); + const contactsPayload = await authService.listContacts(); + setContacts(contactsPayload?.contacts || []); + notifyUnreadStateUpdated(); + showToast('Чат удалён из контактов', { timeoutMs: 1200 }); + navigate('messages-list'); + } catch (error) { + showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 }); + } + }, + }); + }, + }); + }, + }, + ], }) ); @@ -452,6 +871,7 @@ export function render({ navigate, route }) {
+ ${isSpeechToTextReady ? '' : ''}
`; @@ -463,8 +883,20 @@ export function render({ navigate, route }) { const editBannerText = form.querySelector('#chat-edit-banner-text'); const editCancelBtn = form.querySelector('#chat-edit-cancel'); let activeEdit = null; + let activeReply = null; + let inputFocused = false; let emojiPickerOpen = false; let emojiSelection = null; + const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0); + + const syncKeyboardUi = () => { + const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0); + const viewportShrunk = baseViewportHeight - viewportHeight > 120; + setChatKeyboardOpen(inputFocused && viewportShrunk); + if (inputFocused) { + window.requestAnimationFrame(() => scrollToLatestMessage(log)); + } + }; const rememberEmojiSelection = () => { if (!input) return; @@ -505,13 +937,17 @@ export function render({ navigate, route }) { const syncEditBanner = () => { if (!editBanner || !editBannerText) return; - if (!activeEdit) { + if (!activeEdit && !activeReply) { editBanner.hidden = true; editBannerText.textContent = ''; return; } editBanner.hidden = false; - editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`; + if (activeEdit) { + editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`; + return; + } + editBannerText.textContent = `Ответить\n${truncatePreviewText(activeReply?.previewText || '', 110)}`; }; const focusInputToEnd = () => { @@ -537,46 +973,91 @@ export function render({ navigate, route }) { } }; + const cancelReplyMode = ({ restoreDraft = true } = {}) => { + if (!activeReply) return; + activeReply = null; + syncEditBanner(); + if (restoreDraft && input) { + autoResizeComposer(input); + focusInputToEnd(); + } + }; + const startEditMode = (msg) => { const base = parseBaseKey(msg?.baseKey); if (!base || msg?.from !== 'out') return; + const parsed = parseDmTechBlocks(String(msg?.text || '')); const currentDraft = String(input?.value || ''); + activeReply = null; activeEdit = { messageKey: String(msg?.messageKey || ''), - originalText: String(msg?.text || ''), + originalText: String(parsed?.displayText || ''), + prefixText: String(parsed?.prefixText || ''), draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft, timeMs: base.timeMs, nonce: base.nonce, }; syncEditBanner(); if (input) { - input.value = String(msg?.text || ''); + input.value = String(parsed?.visibleText || ''); autoResizeComposer(input); focusInputToEnd(); } }; - const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => { + const startReplyMode = (msg) => { + const base = parseBaseKey(msg?.baseKey); + if (!base) return; + const parsed = parseDmTechBlocks(String(msg?.text || '')); + activeEdit = null; + activeReply = { + baseKey: String(msg?.baseKey || ''), + fromLogin: base.fromLogin, + toLogin: base.toLogin, + previewText: String(parsed?.displayText || parsed?.visibleText || ''), + }; + syncEditBanner(); + focusInputToEnd(); + }; + + const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => { if (!localOutgoingBlobB64) return; try { const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64); + let text = ''; + if (Number(parsed?.messageType || 0) === 1 || Number(parsed?.messageType || 0) === 2) { + const decrypted = await authService.decryptSignedMessageContent({ + parsed, + login: state.session.login, + storagePwd: state.session.storagePwdInMemory, + }); + text = String(decrypted?.text || ''); + } addSignedMessageToChat({ chatId, messageKey: fallbackMessageKey || parsed?.messageKey || '', baseKey: fallbackBaseKey || parsed?.baseKey || '', from: 'out', - text: parsed?.text || '', + text, messageType: Number(parsed?.messageType || 2), unread: false, rawBlobB64: localOutgoingBlobB64, revisionTimeMs: Number(parsed?.revisionTimeMs || 0), deleted: Boolean(parsed?.deleted), }); + return true; } catch { // ignore local parse failure; server backlog/realtime will reconcile later + return false; } }; + const notifyUnreadStateUpdated = () => { + window.dispatchEvent(new CustomEvent('shine-unread-state-updated', { + detail: { chatId }, + })); + }; + const sendDeleteRevision = async (msg) => { const base = parseBaseKey(msg?.baseKey); if (!base) return; @@ -587,11 +1068,13 @@ export function render({ navigate, route }) { timeMs: base.timeMs, nonce: base.nonce, revisionTimeMs: Date.now(), + deleteByRecipient: msg?.from === 'in', }); - applyLocalRevision({ - localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', - fallbackMessageKey: result?.outgoingKey || '', - fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', + addSignedMessageToChat({ + chatId, + messageKey: String(msg?.messageKey || ''), + baseKey: String(msg?.baseKey || ''), + deleted: true, }); if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) { cancelEditMode({ restoreDraft: true }); @@ -600,11 +1083,17 @@ export function render({ navigate, route }) { }; const sendTextMessage = async (rawText) => { - const text = String(rawText || '').trim(); + const safeText = sanitizeUserDmTextForSend(String(rawText || '')); + const text = safeText.trim(); if (!text) return; const editing = activeEdit; + const replying = !editing ? activeReply : null; + const finalText = editing + ? `${String(editing?.prefixText || '')}${text}` + : `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`; const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text); renderLog(log, chatId, { onOpenActions: handleOpenActions }); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); try { let result; @@ -612,7 +1101,7 @@ export function render({ navigate, route }) { result = await authService.sendDirectMessageRevision({ login: state.session.login, toLogin: chatId, - text, + text: finalText, storagePwd: state.session.storagePwdInMemory, timeMs: editing.timeMs, nonce: editing.nonce, @@ -622,7 +1111,7 @@ export function render({ navigate, route }) { result = await authService.sendDirectMessage({ login: state.session.login, toLogin: chatId, - text, + text: finalText, storagePwd: state.session.storagePwdInMemory, }); markOutgoingSent(tempId, { @@ -631,7 +1120,7 @@ export function render({ navigate, route }) { }); } - applyLocalRevision({ + const localRevisionApplied = await applyLocalRevision({ localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', fallbackMessageKey: result?.outgoingKey || '', fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', @@ -639,13 +1128,24 @@ export function render({ navigate, route }) { if (editing) { cancelEditMode({ restoreDraft: true }); + } else if (replying) { + cancelReplyMode({ restoreDraft: false }); } renderLog(log, chatId, { onOpenActions: handleOpenActions }); - scrollToLatestMessage(log); - window.requestAnimationFrame(() => scrollToLatestMessage(log)); - window.setTimeout(() => scrollToLatestMessage(log), 90); - window.setTimeout(() => scrollToLatestMessage(log), 220); + if (localRevisionApplied) { + notifyUnreadStateUpdated(); + } + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true })); + window.setTimeout(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }), 220); + if (input) { + try { + input.focus({ preventScroll: true }); + } catch { + input.focus(); + } + } addAppLogEntry({ level: 'info', source: 'outgoing-dm', @@ -655,6 +1155,7 @@ export function render({ navigate, route }) { messageId: result?.outgoingKey || '', deliveredWsSessions: Number(result?.deliveredWsSessions || 0), deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0), + replyToBaseKey: replying?.baseKey || '', }, }); } catch (e) { @@ -678,12 +1179,21 @@ export function render({ navigate, route }) { }; const handleOpenActions = (msg, event) => { + const parsed = parseDmTechBlocks(String(msg?.text || '')); openMessageActionsMenu({ anchorX: Number(event?.clientX || 0), anchorY: Number(event?.clientY || 0), - messageText: msg?.text || '', - canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2, - canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2, + messageText: parsed?.displayText || msg?.text || '', + canReply: true, + showReadAloud: isTextToSpeechReady, + canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary, + canDelete: ( + (msg?.from === 'out' && Number(msg?.messageType || 0) === 2) + || (msg?.from === 'in' && Number(msg?.messageType || 0) === 1) + ), + onReply: async () => { + startReplyMode(msg); + }, onReadAloud: async () => handleReadAloud(msg), onEdit: async () => { startEditMode(msg); @@ -703,7 +1213,11 @@ export function render({ navigate, route }) { }; editCancelBtn?.addEventListener('click', () => { - cancelEditMode({ restoreDraft: true }); + if (activeEdit) { + cancelEditMode({ restoreDraft: true }); + return; + } + cancelReplyMode({ restoreDraft: true }); }); autoResizeComposer(input); @@ -716,8 +1230,14 @@ export function render({ navigate, route }) { input?.addEventListener('click', rememberEmojiSelection); input?.addEventListener('focus', () => { rememberEmojiSelection(); + inputFocused = true; + syncKeyboardUi(); scrollToLatestMessage(log); }); + input?.addEventListener('blur', () => { + inputFocused = false; + setChatKeyboardOpen(false); + }); emojiToggle?.setAttribute('aria-expanded', 'false'); emojiToggle?.addEventListener('pointerdown', (event) => { rememberEmojiSelection(); @@ -734,28 +1254,31 @@ export function render({ navigate, route }) { }); input?.addEventListener('keydown', async (event) => { if (event.key !== 'Enter') return; - if (event.ctrlKey) { - event.preventDefault(); - const start = Number(input.selectionStart ?? input.value.length); - const end = Number(input.selectionEnd ?? input.value.length); - const value = String(input.value || ''); - input.value = `${value.slice(0, start)}\n${value.slice(end)}`; - const nextPos = start + 1; - try { - input.setSelectionRange(nextPos, nextPos); - } catch { - // ignore - } - autoResizeComposer(input); - return; - } + window.requestAnimationFrame(() => autoResizeComposer(input)); + }); + + form.querySelector('#chat-voice-input')?.addEventListener('click', async () => { + await openSpeechInputModal({ + navigate, + onTextReady: (text) => { + const prev = String(input.value || '').trim(); + input.value = prev ? `${prev} ${text}` : text; + autoResizeComposer(input); + }, + onSendText: async (text) => sendTextMessage(text), + onSendQueued: () => { + showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 }); + }, + }); + }); + + form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { event.preventDefault(); - const text = String(input.value || '').trim(); - if (!text) return; - input.value = ''; - autoResizeComposer(input); - closeEmojiPicker(); - await sendTextMessage(text); + try { + input?.focus({ preventScroll: true }); + } catch { + input?.focus(); + } }); form.addEventListener('submit', async (event) => { @@ -766,14 +1289,50 @@ export function render({ navigate, route }) { autoResizeComposer(input); closeEmojiPicker(); await sendTextMessage(text); + focusInputToEnd(); }); + const handleIncomingChatRefresh = async (event) => { + const updatedChatId = normalizeDmChatId(event?.detail?.chatId); + if (updatedChatId !== chatId) return; + preserveComposerSelection(input, () => { + renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' }); + }); + window.requestAnimationFrame(() => scrollToLatestMessage(log)); + void sendReadReceiptsForVisible(chatId); + }; + + window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); + window.visualViewport?.addEventListener('resize', syncKeyboardUi); + window.addEventListener('resize', syncKeyboardUi); + wrap.append(log, form); screen.append(wrap); - renderLog(log, chatId, { onOpenActions: handleOpenActions }); - window.requestAnimationFrame(() => scrollToLatestMessage(log)); - window.setTimeout(() => scrollToLatestMessage(log), 180); + renderLog(log, chatId, { + onOpenActions: handleOpenActions, + markAsRead: false, + scrollMode: hasUnreadIncoming ? 'unread' : 'latest', + }); + if (hasUnreadIncoming) { + window.requestAnimationFrame(() => scrollToUnreadSeparator(log)); + window.setTimeout(() => scrollToUnreadSeparator(log), 180); + } else { + window.requestAnimationFrame(() => scrollToLatestMessage(log)); + window.setTimeout(() => scrollToLatestMessage(log), 180); + } + window.setTimeout(() => { + if (markChatRead(chatId) > 0) { + notifyUnreadStateUpdated(); + } + }, 220); void sendReadReceiptsForVisible(chatId); + screen.cleanup = () => { + setChatKeyboardOpen(false); + stopAllTelegramEmojiAnimations(); + window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); + window.visualViewport?.removeEventListener('resize', syncKeyboardUi); + window.removeEventListener('resize', syncKeyboardUi); + }; return screen; } @@ -793,7 +1352,6 @@ async function sendReadReceiptsForVisible(chatId) { refFromLogin: ref.fromLogin, refTimeMs: ref.timeMs, refNonce: ref.nonce, - refType: 1, }); if (row.baseKey) { markReadReceiptSentByBaseKey(row.baseKey); diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 5496d7f..9972a60 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -205,7 +205,7 @@ export function render({ navigate }) { Активные заявки
-

Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.

+

Показываются все активные заявки. Для wallet-заявки выпускается отдельная session-only сессия без передачи постоянных ключей.

`; diff --git a/shine-UI/js/pages/devnet-topup-view.js b/shine-UI/js/pages/devnet-topup-view.js index f258de4..adfd836 100644 --- a/shine-UI/js/pages/devnet-topup-view.js +++ b/shine-UI/js/pages/devnet-topup-view.js @@ -1,9 +1,9 @@ import { renderHeader } from '../components/header.js'; import { formatSol, getBalanceSol, transferSol, createSolanaWalletFromPrivateBase58 } from '../services/solana-wallet-service.js'; -import { state } from '../state.js'; export const pageMeta = { id: 'devnet-topup-view', title: 'Пополнение DEVNET', showAppChrome: false }; +const DEVNET_ENDPOINT = 'https://api.devnet.solana.com'; const SENDER_PRIVATE_32_BASE58 = '6xqAuKYvA8qrCdAkcw7Y8aMgvBnYk8JLxWLma5BzbAvu'; const TRANSFER_AMOUNT_SOL = 0.02; @@ -49,7 +49,7 @@ export function render() { status.style.overflowWrap = 'anywhere'; status.style.wordBreak = 'break-word'; status.style.whiteSpace = 'pre-wrap'; - status.textContent = 'Готово к пополнению.'; + status.textContent = `Готово к пополнению.\nСеть: DEVNET (${DEVNET_ENDPOINT})`; const amountHint = document.createElement('p'); amountHint.style.width = 'min(100%, 320px)'; @@ -99,16 +99,14 @@ export function render() { const updateSenderBalance = async () => { if (!senderAddress) return; - const endpoint = state.entrySettings.solanaServer; - const balance = await getBalanceSol({ endpoint, address: senderAddress }); + const balance = await getBalanceSol({ endpoint: DEVNET_ENDPOINT, address: senderAddress }); const senderBalanceEl = senderBox.querySelector('#devnet-topup-sender-balance'); if (senderBalanceEl) senderBalanceEl.textContent = `Баланс: ${formatSol(balance.sol, 6)} SOL`; }; const updateTargetBalance = async () => { if (!targetWallet) return null; - const endpoint = state.entrySettings.solanaServer; - const balance = await getBalanceSol({ endpoint, address: targetWallet }); + const balance = await getBalanceSol({ endpoint: DEVNET_ENDPOINT, address: targetWallet }); const targetBalanceEl = targetBox.querySelector('#devnet-topup-target-balance'); if (targetBalanceEl) targetBalanceEl.textContent = `Баланс: ${formatSol(balance.sol, 6)} SOL`; return balance.sol; @@ -131,9 +129,8 @@ export function render() { closeHint.style.display = 'none'; try { - const endpoint = state.entrySettings.solanaServer; const tx = await transferSol({ - endpoint, + endpoint: DEVNET_ENDPOINT, fromKeypair: senderKeypair, toAddress: targetWallet, amountSol: TRANSFER_AMOUNT_SOL, diff --git a/shine-UI/js/pages/key-storage-view.js b/shine-UI/js/pages/key-storage-view.js index 17b41fa..25e5e38 100644 --- a/shine-UI/js/pages/key-storage-view.js +++ b/shine-UI/js/pages/key-storage-view.js @@ -35,16 +35,20 @@ export function render({ navigate }) { card.innerHTML = `
+

Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.

+

Используется для подписи ваших действий и записей в блокчейне SHiNE.

+

Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.

+

Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.

`; card.children[0].querySelector('label').prepend(rootToggle); diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 29dcd40..7299a77 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -10,6 +10,7 @@ import { import { loadCurrentRelations } from '../services/user-connections.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; +import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; const dmAvatarSnapshotCache = new Map(); @@ -64,6 +65,12 @@ function createDmAvatar(login) { return avatarEl; } +function resolveLastMessagePreview(text = '') { + const parsed = parseDmTechBlocks(String(text || '')); + const display = String(parsed.displayText || '').trim(); + return display || ''; +} + function formatChatRowTime(ts) { const value = Number(ts || 0); if (!Number.isFinite(value) || value <= 0) return ''; @@ -88,12 +95,14 @@ export function render({ navigate }) {
${(login[0] || 'A').toUpperCase()}
- ${login} +

Контакты

`; + const headName = head.querySelector('.dm-head-name'); + if (headName) headName.textContent = login; head.querySelector('.dm-head-plus')?.addEventListener('click', () => navigate('contact-search-view')); const divider = document.createElement('div'); @@ -113,19 +122,25 @@ export function render({ navigate }) { row.innerHTML = `
- ${item.name} + ${item.notInContacts ? 'не в контактах' : ''}
-

${item.lastMessage}

+

${item.unread ? `${item.unread > 99 ? '99+' : item.unread}` : ''}
- ${item.time ? `${item.time}` : ''} + ${item.time ? '' : ''} ${SVG_CHEVRON}
`; + const titleEl = row.querySelector('.dm-row-title'); + const previewEl = row.querySelector('.dm-row-last-message'); + const timeEl = row.querySelector('.dm-row-time'); + if (titleEl) titleEl.textContent = String(item.name || ''); + if (previewEl) previewEl.textContent = resolveLastMessagePreview(item.lastMessage) || 'Диалог пока пуст.'; + if (timeEl) timeEl.textContent = String(item.time || ''); row.prepend(avatarWrap); row.addEventListener('click', () => navigate(`chat-view/${encodeURIComponent(normalizeDmChatId(item.id))}`)); return row; diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 6c71171..b76c113 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -268,16 +268,6 @@ export function render({ navigate, route }) { return makeProfileRoute(cleanLogin); } - function helpText() { - return [ - 'Обозначения на экране связей:', - '• Синие линии — близкие друзья.', - '• Оранжевые линии — родственники.', - '• Стрелка на линии — односторонняя связь.', - '• Если стрелки нет, связь взаимная.', - ].join('\n'); - } - function persistHistory() { persistedCenterLogin = centerLogin; persistedCenterHistory = [...centerHistory]; @@ -523,7 +513,6 @@ export function render({ navigate, route }) { }, rightActions: [ { label: 'Найти', onClick: openSearchModal }, - { label: '?', onClick: () => window.alert(helpText()) }, ], }); const backBtnEl = header.querySelector('.header-left .icon-btn'); diff --git a/shine-UI/js/pages/public-support-queue-view.js b/shine-UI/js/pages/public-support-queue-view.js new file mode 100644 index 0000000..f775a5a --- /dev/null +++ b/shine-UI/js/pages/public-support-queue-view.js @@ -0,0 +1,369 @@ +import { renderHeader } from '../components/header.js'; +import { state } from '../state.js'; +import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; + +export const pageMeta = { id: 'queue', title: 'Очередь билета' }; + +const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v'; +const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE'; +const SHINE_PAYMENTS_SEEDS = { + config: 'shine_payments_config', + coef: 'shine_payments_coef_limit', + queues: 'shine_payments_queues', + q1: 'shine_payments_q1_ticket', + q2: 'shine_payments_q2_ticket', + q3: 'shine_payments_q3_ticket', +}; + +function utf8Bytes(text) { + return new TextEncoder().encode(String(text || '')); +} + +function u64ToBytes(value) { + const out = new Uint8Array(8); + let current = BigInt(value || 0); + for (let i = 0; i < 8; i += 1) { + out[i] = Number(current & 0xffn); + current >>= 8n; + } + return out; +} + +function readU64(data, offset) { + let value = 0n; + for (let i = 0; i < 8; i += 1) { + value |= BigInt(data[offset + i]) << (8n * BigInt(i)); + } + return value; +} + +function readI64(data, offset) { + let value = readU64(data, offset); + if (value > 0x7fffffffffffffffn) { + value -= 0x10000000000000000n; + } + return value; +} + +function readI32(data, offset) { + let value = Number(readU64(data, offset) & 0xffffffffn); + if (value > 0x7fffffff) value -= 0x100000000; + return value; +} + +function formatQueuePrefix(queueId) { + if (queueId === 2) return 'Q2'; + if (queueId === 3) return 'Q3'; + return 'Q1'; +} + +function formatUsdCentsText(cents) { + const value = Number(cents || 0n) / 100; + return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); +} + +function parsePaymentsCoef(data) { + let offset = 0; + const version = data[offset]; + offset += 1; + const coefPpm = readU64(data, offset); + offset += 8; + const limitUsdCents = readU64(data, offset); + offset += 8; + const callRewardLamports = readU64(data, offset); + return { version, coefPpm, limitUsdCents, callRewardLamports }; +} + +function parsePaymentsQueues(data) { + let offset = 0; + const version = data[offset]; + offset += 1; + const q1TicketsTotal = readU64(data, offset); offset += 8; + const q1TicketsPaid = readU64(data, offset); offset += 8; + const q1SumTotalUsdCents = readU64(data, offset); offset += 8; + const q1SumPaidUsdCents = readU64(data, offset); offset += 8; + const q2TicketsTotal = readU64(data, offset); offset += 8; + const q2TicketsPaid = readU64(data, offset); offset += 8; + const q2SumTotalUsdCents = readU64(data, offset); offset += 8; + const q2SumPaidUsdCents = readU64(data, offset); offset += 8; + const q3TicketsTotal = readU64(data, offset); offset += 8; + const q3TicketsPaid = readU64(data, offset); offset += 8; + const q3SumTotalUsdCents = readU64(data, offset); offset += 8; + const q3SumPaidUsdCents = readU64(data, offset); + return { + version, + q1TicketsTotal, + q1TicketsPaid, + q1SumTotalUsdCents, + q1SumPaidUsdCents, + q2TicketsTotal, + q2TicketsPaid, + q2SumTotalUsdCents, + q2SumPaidUsdCents, + q3TicketsTotal, + q3TicketsPaid, + q3SumTotalUsdCents, + q3SumPaidUsdCents, + }; +} + +function parsePaymentsTicket(data) { + let offset = 0; + const version = data[offset]; + offset += 1; + const queueId = data[offset]; + offset += 1; + const index = readU64(data, offset); + offset += 8; + const isPaid = Boolean(data[offset]); + offset += 1; + const solana = window.solanaWeb3; + const recipientWallet = new solana.PublicKey(data.slice(offset, offset + 32)).toBase58(); + offset += 32; + const payoutUsdCents = readU64(data, offset); + offset += 8; + const debtBeforeUsdCents = readU64(data, offset); + return { + version, + queueId, + index, + isPaid, + recipientWallet, + payoutUsdCents, + debtBeforeUsdCents, + }; +} + +function parseSupportTicketInput(rawValue) { + const clean = String(rawValue || '').trim(); + if (!clean) throw new Error('Введите номер билета.'); + if (/^\d+$/.test(clean)) { + return { queueId: 1, index: BigInt(clean) }; + } + const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/); + if (!match) { + throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.'); + } + return { + queueId: Number(match[1]), + index: BigInt(match[2]), + }; +} + +async function loadSupportPaymentsCore(endpoint) { + const solana = await loadSolanaWeb3(); + const rpc = String(endpoint || '').trim(); + const connection = new solana.Connection(rpc, 'confirmed'); + const programId = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID); + const oracleAccount = new solana.PublicKey(SHINE_PAYMENTS_ORACLE_ACCOUNT); + const [configPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.config)], programId); + const [coefPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.coef)], programId); + const [queuesPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.queues)], programId); + const [oracleAi, configAi, coefAi, queuesAi] = await Promise.all([ + connection.getAccountInfo(oracleAccount, 'confirmed'), + connection.getAccountInfo(configPda, 'confirmed'), + connection.getAccountInfo(coefPda, 'confirmed'), + connection.getAccountInfo(queuesPda, 'confirmed'), + ]); + if (!oracleAi) throw new Error('Не найден аккаунт оракула SOL/USD.'); + if (!configAi || !coefAi || !queuesAi) throw new Error('PDA программы оплаты ещё не инициализированы.'); + + const parsePrice = (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 { priceNum: num, priceDen: den, publishTime }; + }; + + return { + connection, + programId, + coef: parsePaymentsCoef(coefAi.data), + queues: parsePaymentsQueues(queuesAi.data), + pyth: parsePrice(oracleAi.data), + }; +} + +function queueStateView(queues, queueId) { + if (queueId === 2) { + return { + ticketsTotal: queues.q2TicketsTotal, + ticketsPaid: queues.q2TicketsPaid, + sumTotalUsdCents: queues.q2SumTotalUsdCents, + sumPaidUsdCents: queues.q2SumPaidUsdCents, + }; + } + if (queueId === 3) { + return { + ticketsTotal: queues.q3TicketsTotal, + ticketsPaid: queues.q3TicketsPaid, + sumTotalUsdCents: queues.q3SumTotalUsdCents, + sumPaidUsdCents: queues.q3SumPaidUsdCents, + }; + } + return { + ticketsTotal: queues.q1TicketsTotal, + ticketsPaid: queues.q1TicketsPaid, + sumTotalUsdCents: queues.q1SumTotalUsdCents, + sumPaidUsdCents: queues.q1SumPaidUsdCents, + }; +} + +function queueSeedFor(queueId) { + if (queueId === 2) return SHINE_PAYMENTS_SEEDS.q2; + if (queueId === 3) return SHINE_PAYMENTS_SEEDS.q3; + return SHINE_PAYMENTS_SEEDS.q1; +} + +function styleInputField(field) { + if (!field) return; + field.style.color = '#111111'; + field.style.webkitTextFillColor = '#111111'; + field.style.caretColor = '#111111'; + field.style.backgroundColor = '#ffffff'; +} + +function readTicketFromUrl() { + try { + const params = new URLSearchParams(window.location.search || ''); + return String(params.get('ticket') || '').trim(); + } catch { + return ''; + } +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + + const content = document.createElement('div'); + content.className = 'stack'; + + const status = document.createElement('p'); + status.className = 'meta-muted'; + status.textContent = 'Страница готова. Введите номер билета.'; + + const card = document.createElement('div'); + card.className = 'card stack'; + card.innerHTML = ` +

Публичная очередь билета

+

+ Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате 2-15 или 3 8. + Страница покажет состояние билета и сколько позиций осталось до выплаты. +

+ `; + + const inputLabel = document.createElement('label'); + inputLabel.className = 'meta-muted'; + inputLabel.setAttribute('for', 'public-queue-ticket-query'); + inputLabel.textContent = 'Номер билета'; + + const queryInput = document.createElement('input'); + queryInput.id = 'public-queue-ticket-query'; + queryInput.type = 'text'; + queryInput.placeholder = 'Например: 12, 2-5 или 3 8'; + queryInput.autocomplete = 'off'; + queryInput.spellcheck = false; + styleInputField(queryInput); + queryInput.value = readTicketFromUrl(); + + const actions = document.createElement('div'); + actions.className = 'row'; + actions.innerHTML = ` + + + `; + + const result = document.createElement('div'); + result.className = 'card stack'; + result.innerHTML = `

Введите номер билета и нажмите кнопку.

`; + + let lastCoreState = null; + + async function renderTicketInfo() { + const raw = String(queryInput.value || '').trim(); + if (!raw) { + result.innerHTML = `

Введите номер билета.

`; + status.textContent = 'Введите номер билета.'; + return; + } + try { + const parsed = parseSupportTicketInput(raw); + const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer); + lastCoreState = core; + const solana = await loadSolanaWeb3(); + const ticketPdaSeed = queueSeedFor(parsed.queueId); + const ticketIndexBytes = u64ToBytes(parsed.index); + const [ticketPda] = solana.PublicKey.findProgramAddressSync( + [utf8Bytes(ticketPdaSeed), ticketIndexBytes], + core.programId, + ); + const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed'); + if (!ticketInfo) { + result.innerHTML = ` +

Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.

+ `; + status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} не найден.`; + return; + } + const ticket = parsePaymentsTicket(ticketInfo.data); + const queueState = queueStateView(core.queues, parsed.queueId); + const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n; + const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore; + const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n; + result.innerHTML = ` +
Билет: ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}
+
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
+
Получатель: ${ticket.recipientWallet}
+
До него в очереди: ${ticketsBefore.toString()} билетов
+
Из них уже выплачено: ${paidBefore.toString()} билетов
+
Ещё осталось до него: ${remainingBefore.toString()} билетов
+
Уже выплачено по сумме в очереди: ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD
+
Сумма этого билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
+
Накоплено до этого билета: ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него
+ `; + status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`; + } catch (error) { + result.innerHTML = `

${String(error?.message || 'Не удалось загрузить билет')}

`; + status.textContent = `Не удалось посмотреть билет: ${error?.message || 'unknown'}`; + } + } + + actions.querySelector('#public-queue-load')?.addEventListener('click', () => { + void renderTicketInfo(); + }); + actions.querySelector('#public-queue-reset')?.addEventListener('click', () => { + queryInput.value = ''; + result.innerHTML = `

Введите номер билета и нажмите кнопку.

`; + status.textContent = 'Поле билета очищено.'; + }); + queryInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void renderTicketInfo(); + } + }); + + content.append(card, inputLabel, queryInput, actions, result, status); + screen.append( + renderHeader({ + title: 'Очередь билета', + leftAction: { label: '←', onClick: () => navigate('start-view') }, + }), + content, + ); + + if (queryInput.value) { + void renderTicketInfo(); + } + + return screen; +} diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index 6639261..a1907d2 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -13,9 +13,39 @@ import { PASSWORD_MAX_LENGTH, PASSWORD_WORDS_COUNT, } from '../services/password-words.js'; +import { sha256Text } from '../services/crypto-utils.js'; +import { defaultServerHttp } from '../deploy-config.js'; import { openRegistrationFaq } from './registration-faq-view.js'; export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false }; +// ВРЕМЕННАЯ UI-ЗАГЛУШКА: +// пока полноценная логика продажи/выдачи коротких имён не внедрена on-chain, +// UI пускает логины 5..7 символов только по временному коду. +const TEMP_MIN_LOGIN_WITHOUT_PROMO = 8; +const TEMP_ABSOLUTE_MIN_LOGIN_LEN = 5; +const TEMP_PROMO_HASH_HEX = 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3'; + +function bytesToHex(bytes) { + return Array.from(bytes || [], (b) => b.toString(16).padStart(2, '0')).join(''); +} + +async function isTemporaryPromoAccepted(value) { + const digest = await sha256Text(String(value || '')); + return bytesToHex(digest) === TEMP_PROMO_HASH_HEX; +} + +function normalizeLoginForTemporaryUiGuard(login) { + const source = String(login || '').trim(); + if (!source || source.length > 20) return null; + let normalized = ''; + for (const ch of source) { + if (ch === '_') continue; + if (!/[0-9A-Za-z]/.test(ch)) return null; + normalized += ch.toLowerCase(); + } + if (!normalized || normalized.length > 20) return null; + return normalized; +} function createWordsLayout({ words, onInput }) { const section = document.createElement('div'); @@ -52,21 +82,46 @@ function createWordsLayout({ words, onInput }) { hint.textContent = 'Здесь можно ввести любые слова на любых языках. Мы не проверяем орфографию. Можно заполнить все 12 полей или только часть. В конце всё склеивается в один пароль длиной до 256 символов.'; + const preview = document.createElement('p'); + preview.className = 'status-line'; + section.append(grid, hint); - return { section, inputs }; + return { section, inputs, preview }; +} + +function makePasswordToggleIcons() { + return { + eye: ` + + `, + eyeOff: ` + + `, + }; } export function render({ navigate }) { const screen = document.createElement('section'); - screen.className = 'stack registration-screen'; + screen.className = 'stack'; clearAuthMessages(); const form = document.createElement('div'); - form.className = 'card stack registration-form'; + form.className = 'card stack'; let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single'; let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords); + let passwordWordsLinked = Boolean(state.registrationDraft.passwordWordsLinked); + let usePromoCode = Boolean(state.registrationDraft.usePromoCode); + let loginCheckTimer = 0; + let loginCheckRunId = 0; const loginInput = document.createElement('input'); loginInput.className = 'input'; @@ -85,16 +140,31 @@ export function render({ navigate }) { passwordInput.autocapitalize = 'off'; passwordInput.spellcheck = false; passwordInput.maxLength = PASSWORD_MAX_LENGTH; - passwordInput.value = passwordMode === 'single' ? state.registrationDraft.password : ''; + passwordInput.value = String(state.registrationDraft.password || ''); passwordInput.placeholder = 'Введите пароль'; + const passwordIcons = makePasswordToggleIcons(); + + const passwordToggleButton = document.createElement('button'); + passwordToggleButton.className = 'icon-btn key-toggle-btn registration-password-toggle'; + passwordToggleButton.type = 'button'; + passwordToggleButton.setAttribute('aria-label', 'Показать пароль'); + passwordToggleButton.setAttribute('title', 'Показать пароль'); + passwordToggleButton.innerHTML = passwordIcons.eyeOff; + + const passwordInputRow = document.createElement('div'); + passwordInputRow.className = 'inline-input-row'; + passwordInputRow.append(passwordInput, passwordToggleButton); + const { section: wordsSection, inputs: wordInputs, + preview: wordsPreview, } = createWordsLayout({ words: passwordWords, onInput: (index, value) => { passwordWords[index] = value; + passwordWordsLinked = true; syncDraftState(); updateWordsPreview(); }, @@ -112,23 +182,76 @@ export function render({ navigate }) { passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel); + const promoToggle = document.createElement('label'); + promoToggle.className = 'registration-toggle'; + + const promoCheckbox = document.createElement('input'); + promoCheckbox.type = 'checkbox'; + promoCheckbox.checked = usePromoCode; + + const promoToggleLabel = document.createElement('span'); + promoToggleLabel.textContent = 'У меня есть промокод'; + + promoToggle.append(promoCheckbox, promoToggleLabel); + + const promoField = document.createElement('label'); + promoField.className = 'stack'; + + const promoFieldLabel = document.createElement('span'); + promoFieldLabel.className = 'field-label'; + promoFieldLabel.textContent = 'Промокод'; + + const promoInput = document.createElement('input'); + promoInput.className = 'input'; + promoInput.type = 'text'; + promoInput.autocomplete = 'off'; + promoInput.autocapitalize = 'off'; + promoInput.spellcheck = false; + promoInput.value = String(state.registrationDraft.promoCode || ''); + promoInput.placeholder = 'Вставьте промокод'; + + const promoHint = document.createElement('p'); + promoHint.className = 'meta-muted'; + promoHint.textContent = 'Временный режим: логины длиной 5-7 символов доступны только по специальному коду. Любое другое значение считается неверным промокодом.'; + + promoField.append(promoFieldLabel, promoInput, promoHint); + const statusText = document.createElement('p'); - statusText.className = 'status-line registration-login-status'; + statusText.className = 'status-line'; statusText.style.display = 'none'; + const serverNotice = document.createElement('div'); + serverNotice.className = 'card stack'; + serverNotice.innerHTML = ` +

Первый сервер SHiNE

+

При регистрации адресом вашего первого сервера будет: ${state.entrySettings.shineServerHttp || defaultServerHttp}.

+ `; + + const formError = document.createElement('p'); + formError.className = 'status-line is-unavailable'; + formError.style.display = 'none'; + + // Компактная ссылка на экран «Вопросы о регистрации» (registration-faq-view). const faqButton = document.createElement('button'); faqButton.className = 'registration-faq-link'; faqButton.type = 'button'; faqButton.textContent = 'Вопросы о регистрации'; faqButton.addEventListener('click', () => openRegistrationFaq(navigate, 'key-derivation')); - const formError = document.createElement('p'); - formError.className = 'status-line is-unavailable'; - formError.style.display = 'none'; + const checkButton = document.createElement('button'); + checkButton.className = 'ghost-btn'; + checkButton.type = 'button'; + checkButton.textContent = 'Проверить логин'; const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; + const backButton = document.createElement('button'); + backButton.className = 'ghost-btn'; + backButton.type = 'button'; + backButton.textContent = 'Назад'; + backButton.addEventListener('click', () => navigate('start-view')); + const nextButton = document.createElement('button'); nextButton.className = 'primary-btn'; nextButton.type = 'button'; @@ -136,12 +259,10 @@ export function render({ navigate }) { let passwordField = null; const passwordLengthText = document.createElement('p'); - passwordLengthText.className = 'password-length-hint'; + passwordLengthText.className = 'status-line'; let lastCheckedLogin = ''; let lastCheckedFree = false; let lastCheckedClassName = ''; - let loginCheckTimer = null; - let loginCheckRunId = 0; let generationRunId = 0; function getCurrentPassword() { @@ -151,14 +272,50 @@ export function render({ navigate }) { function updateWordsPreview() { const password = getCurrentPassword(); const text = `Итоговая длина пароля: ${password.length} символов.`; + wordsPreview.textContent = text; passwordLengthText.textContent = text; } + function setStatusMessage(message, kind = '') { + statusText.textContent = message; + statusText.className = kind ? `status-line ${kind}` : 'status-line'; + statusText.style.display = message ? '' : 'none'; + } + + function resetLoginCheckState() { + lastCheckedLogin = ''; + lastCheckedFree = false; + lastCheckedClassName = ''; + loginCheckRunId += 1; + if (loginCheckTimer) { + window.clearTimeout(loginCheckTimer); + loginCheckTimer = 0; + } + } + + function scheduleAvailabilityCheck() { + resetLoginCheckState(); + setStatusMessage(''); + if (!loginInput.value.trim()) return; + loginCheckTimer = window.setTimeout(() => { + loginCheckTimer = 0; + runAvailabilityCheck({ automatic: true }).catch(() => {}); + }, 1500); + } + + function togglePasswordVisibility() { + const reveal = passwordInput.type === 'password'; + passwordInput.type = reveal ? 'text' : 'password'; + passwordToggleButton.innerHTML = reveal ? passwordIcons.eye : passwordIcons.eyeOff; + passwordToggleButton.setAttribute('aria-label', reveal ? 'Скрыть пароль' : 'Показать пароль'); + passwordToggleButton.setAttribute('title', reveal ? 'Скрыть пароль' : 'Показать пароль'); + } + function updatePasswordModeVisibility() { const wordsMode = passwordMode === 'words'; wordsSection.style.display = wordsMode ? 'grid' : 'none'; if (passwordField) passwordField.style.display = wordsMode ? 'none' : 'grid'; - passwordInput.style.display = wordsMode ? 'none' : ''; + passwordInputRow.style.display = wordsMode ? 'grid' : 'none'; updateWordsPreview(); } @@ -166,114 +323,158 @@ export function render({ navigate }) { state.registrationDraft.login = String(loginInput.value.trim()); state.registrationDraft.passwordMode = passwordMode; state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords); + state.registrationDraft.passwordWordsLinked = passwordWordsLinked; state.registrationDraft.password = getCurrentPassword(); + state.registrationDraft.usePromoCode = usePromoCode; + state.registrationDraft.promoCode = String(promoInput.value || '').trim(); } - async function runAvailabilityCheck() { + function updatePromoVisibility() { + promoField.style.display = usePromoCode ? 'grid' : 'none'; + } + + async function runAvailabilityCheck({ automatic = false } = {}) { + const runId = ++loginCheckRunId; const login = loginInput.value.trim(); + const promoCode = String(promoInput.value || '').trim(); if (!login) { - statusText.textContent = ''; - statusText.style.display = 'none'; + setStatusMessage(automatic ? '' : 'Введите логин'); + formError.style.display = 'none'; + return false; + } + + const normalizedLogin = normalizeLoginForTemporaryUiGuard(login); + if (!normalizedLogin) { + setStatusMessage('Логин содержит недопустимые символы или имеет неверную длину ❌', 'is-unavailable'); + formError.style.display = 'none'; + return false; + } + + if (normalizedLogin.length < TEMP_ABSOLUTE_MIN_LOGIN_LEN) { + setStatusMessage(`Логин должен быть не короче ${TEMP_ABSOLUTE_MIN_LOGIN_LEN} символов ❌`, 'is-unavailable'); + formError.style.display = 'none'; + return false; + } + + const wantsShortLogin = normalizedLogin.length < TEMP_MIN_LOGIN_WITHOUT_PROMO; + let promoAccepted = false; + if (usePromoCode) { + if (!promoCode) { + setStatusMessage('Введите промокод ❌', 'is-unavailable'); + formError.style.display = 'none'; + return false; + } + promoAccepted = await isTemporaryPromoAccepted(promoCode); + if (!promoAccepted) { + setStatusMessage('Неверный промокод ❌', 'is-unavailable'); + formError.style.display = 'none'; + return false; + } + } else if (wantsShortLogin) { + setStatusMessage(`Логины короче ${TEMP_MIN_LOGIN_WITHOUT_PROMO} символов временно доступны только по специальному коду ❌`, 'is-unavailable'); formError.style.display = 'none'; return false; } if (login === lastCheckedLogin) { if (!lastCheckedFree) { - statusText.textContent = 'Логин уже занят ❌'; - statusText.className = 'status-line is-unavailable'; + setStatusMessage('Логин уже занят ❌', 'is-unavailable'); + } else if (lastCheckedClassName === 'promo') { + setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available'); } else if (lastCheckedClassName === 'free') { - statusText.textContent = 'Логин свободен ✅'; - statusText.className = 'status-line is-available'; + setStatusMessage('Логин свободен ✅', 'is-available'); } else if (lastCheckedClassName === 'premium') { - statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; - statusText.className = 'status-line is-unavailable'; + setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable'); } else if (lastCheckedClassName === 'company') { - statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; - statusText.className = 'status-line is-unavailable'; + setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable'); } else { - statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; - statusText.className = 'status-line is-unavailable'; + setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable'); } - statusText.style.display = ''; formError.style.display = 'none'; - return lastCheckedFree && lastCheckedClassName === 'free'; + return lastCheckedFree && (lastCheckedClassName === 'free' || lastCheckedClassName === 'promo'); } - const runId = ++loginCheckRunId; - statusText.className = 'status-line registration-login-status'; - statusText.textContent = 'Проверяем логин...'; - statusText.style.display = ''; + checkButton.disabled = true; + checkButton.textContent = 'Проверка...'; + if (automatic) setStatusMessage('Проверяем логин...'); try { const check = await checkLoginExistsOnSolana({ login, solanaEndpoint: state.entrySettings.solanaServer, }); + if (runId !== loginCheckRunId) return false; const isFree = !check.exists; let className = ''; let precheckWarning = ''; if (isFree) { - try { - const precheck = await precheckLoginClassOnSolana({ - login, - solanaEndpoint: state.entrySettings.solanaServer, - }); - className = precheck.className; - } catch (precheckError) { - className = 'free'; - precheckWarning = formatSolanaErrorDetails(precheckError); + if (promoAccepted) { + className = 'promo'; + } else { + try { + const precheck = await precheckLoginClassOnSolana({ + login, + solanaEndpoint: state.entrySettings.solanaServer, + }); + className = precheck.className; + } catch (precheckError) { + className = 'free'; + precheckWarning = formatSolanaErrorDetails(precheckError); + } } } - if (runId !== loginCheckRunId) return false; lastCheckedLogin = login; lastCheckedFree = isFree; lastCheckedClassName = className; if (!isFree) { - statusText.textContent = 'Логин уже занят ❌'; - statusText.className = 'status-line is-unavailable'; + setStatusMessage('Логин уже занят ❌', 'is-unavailable'); + } else if (className === 'promo') { + setStatusMessage('Логин свободен ✅ Временный код принят', 'is-available'); } else if (className === 'free') { - statusText.textContent = precheckWarning - ? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})` - : 'Логин свободен ✅'; - statusText.className = 'status-line is-available'; + setStatusMessage( + precheckWarning + ? `Логин свободен ✅ (предпроверка Solana недоступна: ${precheckWarning})` + : 'Логин свободен ✅', + 'is-available', + ); } else if (className === 'premium') { - statusText.textContent = 'Логин свободен, но это премиум-логин (покупка через DAO) ❌'; - statusText.className = 'status-line is-unavailable'; + setStatusMessage('Логин свободен, но это премиум-логин (покупка через DAO) ❌', 'is-unavailable'); } else if (className === 'company') { - statusText.textContent = 'Логин свободен, но относится к компании/бренду (отдельное согласование) ❌'; - statusText.className = 'status-line is-unavailable'; + setStatusMessage('Логин свободен, но относится к компании/бренду (отдельное согласование) ❌', 'is-unavailable'); } else { - statusText.textContent = 'Логин нельзя использовать для обычной регистрации ❌'; - statusText.className = 'status-line is-unavailable'; + setStatusMessage('Логин нельзя использовать для обычной регистрации ❌', 'is-unavailable'); } - statusText.style.display = ''; formError.style.display = 'none'; - return isFree && className === 'free'; + return isFree && (className === 'free' || className === 'promo'); } catch (error) { if (runId !== loginCheckRunId) return false; const base = toUserMessage(error, 'Не удалось проверить логин'); const details = formatSolanaErrorDetails(error); - statusText.textContent = `${base}. Детали: ${details}`; - statusText.className = 'status-line is-unavailable'; - statusText.style.display = ''; + setStatusMessage(`${base}. Детали: ${details}`, 'is-unavailable'); return false; + } finally { + if (runId === loginCheckRunId) { + checkButton.disabled = false; + checkButton.textContent = 'Проверить логин'; + } } } + checkButton.addEventListener('click', () => runAvailabilityCheck()); + passwordToggleButton.addEventListener('click', togglePasswordVisibility); + loginInput.addEventListener('input', () => { syncDraftState(); - lastCheckedLogin = ''; - loginCheckRunId += 1; - if (loginCheckTimer) window.clearTimeout(loginCheckTimer); - statusText.textContent = ''; - statusText.style.display = 'none'; - if (!loginInput.value.trim()) return; - loginCheckTimer = window.setTimeout(() => { - runAvailabilityCheck(); - }, 450); + scheduleAvailabilityCheck(); }); passwordInput.addEventListener('input', () => { + if (passwordWordsLinked && String(passwordInput.value || '') !== composePasswordFromWords(passwordWords)) { + passwordWords = emptyPasswordWords(); + passwordWordsLinked = false; + wordInputs.forEach((input) => { + input.value = ''; + }); + } syncDraftState(); updateWordsPreview(); }); @@ -282,13 +483,16 @@ export function render({ navigate }) { const nextMode = passwordModeCheckbox.checked ? 'words' : 'single'; if (nextMode === passwordMode) return; if (nextMode === 'words') { - passwordWords = emptyPasswordWords(); - wordInputs.forEach((input) => { - input.value = ''; - }); - passwordInput.value = ''; + if (!passwordWordsLinked) { + passwordWords = emptyPasswordWords(); + wordInputs.forEach((input) => { + input.value = ''; + }); + passwordInput.value = ''; + } } else { passwordInput.value = composePasswordFromWords(passwordWords); + passwordWordsLinked = true; } passwordMode = nextMode; updatePasswordModeVisibility(); @@ -296,8 +500,33 @@ export function render({ navigate }) { syncDraftState(); }); + promoCheckbox.addEventListener('change', () => { + usePromoCode = promoCheckbox.checked; + resetLoginCheckState(); + updatePromoVisibility(); + syncDraftState(); + if (usePromoCode) { + setStatusMessage('Временный код включён: для логинов 5-7 символов будет локальная проверка', 'is-available'); + } else { + setStatusMessage(''); + scheduleAvailabilityCheck(); + } + }); + + promoInput.addEventListener('input', () => { + resetLoginCheckState(); + syncDraftState(); + if (usePromoCode) scheduleAvailabilityCheck(); + }); + nextButton.addEventListener('click', async () => { formError.style.display = 'none'; + const promoCode = String(promoInput.value || '').trim(); + if (usePromoCode && !promoCode) { + formError.textContent = 'Если включён временный код, поле должно быть заполнено.'; + formError.style.display = ''; + return; + } const isFree = await runAvailabilityCheck(); if (!isFree) return; @@ -321,6 +550,9 @@ export function render({ navigate }) { state.registrationDraft.password = nextPassword; state.registrationDraft.passwordMode = passwordMode; state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords); + state.registrationDraft.passwordWordsLinked = passwordWordsLinked; + state.registrationDraft.usePromoCode = usePromoCode; + state.registrationDraft.promoCode = promoCode; if (credsChanged) { state.registrationDraft.preGeneratedKeyBundle = null; } @@ -329,22 +561,28 @@ export function render({ navigate }) { }); function renderInputStage() { - form.innerHTML = ` - - - `; - const loginField = form.children[0]; - passwordField = form.children[1]; + serverNotice.style.display = ''; + form.innerHTML = ''; + const loginField = document.createElement('label'); + loginField.className = 'stack'; + loginField.innerHTML = 'Логин'; + const passwordLabel = document.createElement('label'); + passwordLabel.className = 'stack registration-password-single'; + passwordLabel.innerHTML = 'Пароль'; + form.append(loginField, statusText, checkButton, passwordLabel); + passwordField = passwordLabel; loginField.append(loginInput); - passwordField.append(passwordInput, passwordLengthText); - form.append(passwordModeToggle, wordsSection, statusText, formError); + passwordField.append(passwordInputRow); + form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, formError, faqButton); actions.innerHTML = ''; - actions.append(nextButton); + actions.append(backButton, nextButton); updatePasswordModeVisibility(); + updatePromoVisibility(); syncDraftState(); } async function startGenerationStage() { + serverNotice.style.display = 'none'; const runId = ++generationRunId; form.innerHTML = ''; @@ -450,7 +688,7 @@ export function render({ navigate }) { leftAction: { label: '←', onClick: () => navigate('start-view') }, }), form, - faqButton, + serverNotice, actions, ); diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index 461f08a..f66f697 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -42,6 +42,22 @@ export function render({ navigate }) { status.className = 'status-line is-unavailable'; status.style.display = 'none'; + const createKeyInfo = (toggle, titleText, descriptionText) => { + const wrap = document.createElement('div'); + wrap.className = 'key-storage-option stack'; + + const row = document.createElement('label'); + row.className = 'checkbox-row'; + row.append(toggle, document.createTextNode(titleText)); + + const description = document.createElement('p'); + description.className = 'meta-muted key-storage-option__description'; + description.textContent = descriptionText; + + wrap.append(row, description); + return wrap; + }; + const rootToggle = document.createElement('input'); rootToggle.type = 'checkbox'; rootToggle.checked = state.keyStorage.saveRoot; @@ -55,19 +71,29 @@ export function render({ navigate }) { deviceToggle.checked = true; deviceToggle.disabled = true; - const rootRow = document.createElement('label'); - rootRow.className = 'checkbox-row'; - rootRow.append(rootToggle, document.createTextNode('Ключ root')); + const rootRow = createKeyInfo( + rootToggle, + 'Ключ root', + 'Главный ключ аккаунта. Нужен для смены пароля, восстановления доступа и важных основных настроек.', + ); - const blockchainRow = document.createElement('label'); - blockchainRow.className = 'checkbox-row'; - blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain')); + const blockchainRow = createKeyInfo( + blockchainToggle, + 'Ключ blockchain', + 'Используется для подписи ваших действий и записей в блокчейне SHiNE.', + ); - const deviceRow = document.createElement('label'); - deviceRow.className = 'checkbox-row'; - deviceRow.append(deviceToggle, document.createTextNode('Ключ device (всегда)')); + const deviceRow = createKeyInfo( + deviceToggle, + 'Ключ device (всегда)', + 'Ключ этого устройства. Нужен для обычного входа, авторизации сессии и работы приложения на телефоне.', + ); - card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, status); + const simpleNote = document.createElement('p'); + simpleNote.className = 'key-storage-note key-storage-note--strong'; + simpleNote.textContent = 'Если вы не особо понимаете, о чём идёт речь, и не хотите особо заморачиваться с ключами, можете просто сохранить все ключи на телефоне.'; + + card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, simpleNote, status); const actions = document.createElement('div'); actions.className = 'auth-footer-actions'; @@ -129,6 +155,9 @@ export function render({ navigate }) { state.registrationDraft.password = ''; state.registrationDraft.passwordMode = 'single'; state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice(); + state.registrationDraft.passwordWordsLinked = false; + state.registrationDraft.usePromoCode = false; + state.registrationDraft.promoCode = ''; state.registrationDraft.storagePwd = ''; state.registrationDraft.sessionId = ''; state.registrationDraft.pendingKeyBundle = null; diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index 326bf18..639b753 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -14,6 +14,7 @@ import { getBalanceSol, getTopupSiteUrl, } from '../services/solana-wallet-service.js'; +import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; import { formatSolanaErrorDetails, isUserAlreadyExistsSolanaError, @@ -81,6 +82,9 @@ async function completeRegistrationLogin({ navigate, keyBundle }) { state.registrationDraft.password = ''; state.registrationDraft.passwordMode = 'single'; state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice(); + state.registrationDraft.passwordWordsLinked = false; + state.registrationDraft.usePromoCode = false; + state.registrationDraft.promoCode = ''; state.registrationDraft.storagePwd = ''; state.registrationDraft.sessionId = ''; state.registrationDraft.pendingKeyBundle = null; @@ -185,7 +189,7 @@ export function render({ navigate }) { const raw = atob(publicKeyB64); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); - const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4'); + const { PublicKey } = await loadSolanaWeb3(); const address = new PublicKey(bytes).toBase58(); state.registrationPayment.walletAddress = address; walletValue.value = address; @@ -263,6 +267,7 @@ export function render({ navigate }) { keyBundle, solanaEndpoint: state.entrySettings.solanaServer, accessServers: [state.entrySettings.shineServerLogin || defaultServerLogin], + promoCode: '', }); } catch (solanaError) { const solanaMsg = formatSolanaErrorDetails(solanaError); diff --git a/shine-UI/js/pages/remote-addblock-session-view.js b/shine-UI/js/pages/remote-addblock-session-view.js new file mode 100644 index 0000000..12d2a5a --- /dev/null +++ b/shine-UI/js/pages/remote-addblock-session-view.js @@ -0,0 +1,147 @@ +import { renderHeader } from '../components/header.js'; +import { + isSessionInvalidError, + refreshSessions, + saveEntrySettings, + setAuthError, + setAuthInfo, + state, + terminateCurrentSession, +} from '../state.js'; + +export const pageMeta = { id: 'remote-addblock-session-view', title: 'AddBlock через homeserver' }; + +const SESSION_TYPE_HOMESERVER = 100; + +function formatSessionTime(ms) { + return new Date(ms).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function sessionLabel(session) { + const title = String(session?.clientInfoFromClient || '').trim(); + if (title) return title; + return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`; +} + +export function render({ navigate }) { + const screen = document.createElement('section'); + screen.className = 'stack'; + + screen.append( + renderHeader({ + title: 'AddBlock через homeserver', + leftAction: { label: '←', onClick: () => navigate('settings-view') }, + }), + ); + + const actions = document.createElement('div'); + actions.className = 'card stack'; + actions.innerHTML = ` +

Если на устройстве нет локального blockchain key, UI сможет отправлять AddBlock в выбранную homeserver-сессию. Homeserver подпишет блок своим blockchain key и сам отправит обычный AddBlock на сервер.

+ + + `; + + const listCard = document.createElement('div'); + listCard.className = 'card stack'; + + const buildList = () => { + listCard.innerHTML = ''; + const selectedId = String(state.entrySettings.remoteAddBlockSessionId || '').trim(); + const sessions = (state.sessions || []) + .filter((item) => Number(item?.sessionType || 0) === SESSION_TYPE_HOMESERVER) + .sort((a, b) => Number(b?.lastAuthenticatedAtMs || 0) - Number(a?.lastAuthenticatedAtMs || 0)); + + if (sessions.length === 0) { + const empty = document.createElement('p'); + empty.className = 'meta-muted'; + empty.textContent = 'Активных homeserver-сессий не найдено.'; + listCard.append(empty); + return; + } + + sessions.forEach((session) => { + const sessionId = String(session?.sessionId || '').trim(); + const item = document.createElement('button'); + item.className = 'session-item'; + item.type = 'button'; + const isSelected = sessionId && sessionId === selectedId; + item.innerHTML = ` +
+ ${sessionLabel(session)} + ${session.geo || 'unknown'} · ${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())} + sessionId: ${sessionId || '-'} + status: ${session.onlineOnThisServer ? 'online' : 'offline on this server'} + ${isSelected ? 'Выбрана для remote AddBlock' : ''} +
+ `; + item.addEventListener('click', async () => { + try { + await saveEntrySettings({ + ...state.entrySettings, + remoteAddBlockSessionId: sessionId, + }); + buildList(); + setAuthInfo('Homeserver-сессия для remote AddBlock сохранена.'); + } catch (error) { + if (isSessionInvalidError(error)) { + await terminateCurrentSession({ + infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.', + }); + return; + } + setAuthError(error.message); + window.alert(error.message); + } + }); + listCard.append(item); + }); + }; + + actions.querySelector('#remote-addblock-refresh')?.addEventListener('click', async () => { + try { + await refreshSessions(); + buildList(); + setAuthInfo('Список homeserver-сессий обновлён.'); + } catch (error) { + if (isSessionInvalidError(error)) { + await terminateCurrentSession({ + infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.', + }); + return; + } + setAuthError(error.message); + window.alert(error.message); + } + }); + + actions.querySelector('#remote-addblock-clear')?.addEventListener('click', async () => { + try { + await saveEntrySettings({ + ...state.entrySettings, + remoteAddBlockSessionId: '', + }); + buildList(); + setAuthInfo('Выбор homeserver-сессии для remote AddBlock сброшен.'); + } catch (error) { + if (isSessionInvalidError(error)) { + await terminateCurrentSession({ + infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.', + }); + return; + } + setAuthError(error.message); + window.alert(error.message); + } + }); + + buildList(); + screen.append(actions, listCard); + return screen; +} diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 258aa20..e946644 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -45,6 +45,7 @@ export function render({ navigate }) { + @@ -57,6 +58,7 @@ export function render({ navigate }) { navigate(makeProfileLinksRoute(state.session.login)); }); card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view')); + card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view')); card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view')); card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view')); card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view')); diff --git a/shine-UI/js/pages/solana-users-init-view.js b/shine-UI/js/pages/solana-users-init-view.js index 3c85e78..86ad66e 100644 --- a/shine-UI/js/pages/solana-users-init-view.js +++ b/shine-UI/js/pages/solana-users-init-view.js @@ -5,13 +5,14 @@ import { SOLANA_CLUSTER, } from '../solana-programs.js'; import { state } from '../state.js'; +import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; export const pageMeta = { id: 'solana-users-init-view', title: 'Solana Init (users)' }; let solanaLibPromise = null; function loadSolanaLib() { if (!solanaLibPromise) { - solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); + solanaLibPromise = loadSolanaWeb3(); } return solanaLibPromise; } diff --git a/shine-UI/js/pages/topup-view.js b/shine-UI/js/pages/topup-view.js index 36038f1..2fb17fd 100644 --- a/shine-UI/js/pages/topup-view.js +++ b/shine-UI/js/pages/topup-view.js @@ -6,6 +6,7 @@ import { getTopupSiteUrl, requestAirdropSol, } from '../services/solana-wallet-service.js'; +import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false }; @@ -20,7 +21,7 @@ async function clientWalletAddressFromBundle() { const raw = atob(keyBundle.clientPair.publicKeyB64); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i); - const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4'); + const { PublicKey } = await loadSolanaWeb3(); return new PublicKey(bytes).toBase58(); } diff --git a/shine-UI/js/pages/wallet-view.js b/shine-UI/js/pages/wallet-view.js index dfe810d..01da7f7 100644 --- a/shine-UI/js/pages/wallet-view.js +++ b/shine-UI/js/pages/wallet-view.js @@ -1,8 +1,6 @@ import { renderHeader } from '../components/header.js'; import { authService, state } from '../state.js'; import { - createRandomSolanaWallet, - createSolanaWalletFromPrivateBase58, formatSol, getBalanceSol, getTopupSiteUrl, @@ -17,6 +15,7 @@ import { transferAr, } from '../services/arweave-wallet-service.js'; import { loadEncryptedUserSecrets } from '../services/key-vault.js'; +import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; import { calcLimitTopupPriceLamports, getLimitStepBytes, @@ -26,7 +25,6 @@ import { } from '../services/shine-blockchain-wallet-service.js?v=202605300007'; export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' }; -const SOLANA_PRIVATE_BASE58_MAX_LEN = 44; function nowRu() { return new Date().toLocaleString('ru-RU'); @@ -65,6 +63,338 @@ function sessionArgsOrThrow() { return { login, storagePwd }; } +const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v'; +const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE'; +const SHINE_PAYMENTS_SEEDS = { + config: 'shine_payments_config', + coef: 'shine_payments_coef_limit', + queues: 'shine_payments_queues', + inflow: 'shine_payments_inflow_vault', + q1: 'shine_payments_q1_ticket', + q2: 'shine_payments_q2_ticket', + q3: 'shine_payments_q3_ticket', +}; +const COEF_SCALE_PPM = 1_000_000n; +const LAMPORTS_PER_SOL = 1_000_000_000n; +const SUPPORT_TICKET_HELP_TEXT = [ + 'Билет привязан к очереди 1 и к адресу получателя, который вы укажете при покупке.', + 'Оплата считается в USD, а списание идет в SOL по курсу USDT/SOL, который проверяется в момент транзакции.', + 'Если курс уезжает слишком далеко, покупка отклоняется. Для интерфейса заложен допуск 3%.', + 'Когда текущий лимит очереди заполнится, откроется новый лимит с более низким коэффициентом.', + 'Номер билета нужно сохранить отдельно: именно по нему удобно отслеживать, когда он дойдет до выплаты.', +].join('\n'); + +function utf8Bytes(text) { + return new TextEncoder().encode(String(text || '')); +} + +function concatBytes(...parts) { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + parts.forEach((part) => { + out.set(part, offset); + offset += part.length; + }); + return out; +} + +const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +function encodeBase58(bytes) { + const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []); + if (source.length === 0) return ''; + + const digits = [0]; + for (let i = 0; i < source.length; i += 1) { + let carry = source[i]; + for (let j = 0; j < digits.length; j += 1) { + const value = (digits[j] << 8) + carry; + digits[j] = value % 58; + carry = Math.floor(value / 58); + } + while (carry > 0) { + digits.push(carry % 58); + carry = Math.floor(carry / 58); + } + } + + for (let i = 0; i < source.length && source[i] === 0; i += 1) { + digits.push(0); + } + + return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join(''); +} + +function u64ToBytes(value) { + const out = new Uint8Array(8); + let current = BigInt(value || 0); + for (let i = 0; i < 8; i += 1) { + out[i] = Number(current & 0xffn); + current >>= 8n; + } + return out; +} + +function readU64(data, offset) { + let value = 0n; + for (let i = 0; i < 8; i += 1) { + value |= BigInt(data[offset + i]) << (8n * BigInt(i)); + } + return value; +} + +function readI64(data, offset) { + let value = readU64(data, offset); + if (value > 0x7fffffffffffffffn) { + value -= 0x10000000000000000n; + } + return value; +} + +function readI32(data, offset) { + let value = Number(readU64(data, offset) & 0xffffffffn); + if (value > 0x7fffffff) value -= 0x100000000; + return value; +} + +function trimZeros(value) { + return String(value || '') + .replace(/(\.\d*?[1-9])0+$/u, '$1') + .replace(/\.0+$/u, '') + .replace(/\.$/u, ''); +} + +function formatUsdCentsText(cents) { + const value = Number(cents || 0n) / 100; + return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); +} + +function formatPpmCoefText(coefPpm) { + const value = Number(coefPpm || 0n) / Number(COEF_SCALE_PPM); + return `${trimZeros(value.toFixed(6))}x`; +} + +function formatLamportsSolText(lamports, digits = 9) { + const value = Number(lamports || 0n) / Number(LAMPORTS_PER_SOL); + return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: digits }); +} + +function formatPythSolUsdText(pyth) { + const value = Number(pyth?.priceNum || 0n) / Number(pyth?.priceDen || 1n) / 100; + return trimZeros(value.toFixed(6)); +} + +function formatQueuePrefix(queueId) { + if (queueId === 2) return 'Q2'; + if (queueId === 3) return 'Q3'; + return 'Q1'; +} + +function parsePaymentsCoef(data) { + let offset = 0; + const version = data[offset]; + offset += 1; + const coefPpm = readU64(data, offset); offset += 8; + const limitUsdCents = readU64(data, offset); offset += 8; + const callRewardLamports = readU64(data, offset); + return { version, coefPpm, limitUsdCents, callRewardLamports }; +} + +function parsePaymentsQueues(data) { + let offset = 0; + const version = data[offset]; + offset += 1; + const q1TicketsTotal = readU64(data, offset); offset += 8; + const q1TicketsPaid = readU64(data, offset); offset += 8; + const q1SumTotalUsdCents = readU64(data, offset); offset += 8; + const q1SumPaidUsdCents = readU64(data, offset); offset += 8; + const q2TicketsTotal = readU64(data, offset); offset += 8; + const q2TicketsPaid = readU64(data, offset); offset += 8; + const q2SumTotalUsdCents = readU64(data, offset); offset += 8; + const q2SumPaidUsdCents = readU64(data, offset); offset += 8; + const q3TicketsTotal = readU64(data, offset); offset += 8; + const q3TicketsPaid = readU64(data, offset); offset += 8; + const q3SumTotalUsdCents = readU64(data, offset); offset += 8; + const q3SumPaidUsdCents = readU64(data, offset); + return { + version, + q1TicketsTotal, + q1TicketsPaid, + q1SumTotalUsdCents, + q1SumPaidUsdCents, + q2TicketsTotal, + q2TicketsPaid, + q2SumTotalUsdCents, + q2SumPaidUsdCents, + q3TicketsTotal, + q3TicketsPaid, + q3SumTotalUsdCents, + q3SumPaidUsdCents, + }; +} + +function parsePaymentsTicket(data) { + let offset = 0; + const version = data[offset]; + offset += 1; + const queueId = data[offset]; + offset += 1; + const index = readU64(data, offset); + offset += 8; + const isPaid = Boolean(data[offset]); + offset += 1; + const solana = window.solanaWeb3; + const recipientWallet = new solana.PublicKey(data.slice(offset, offset + 32)).toBase58(); + offset += 32; + const payoutUsdCents = readU64(data, offset); + offset += 8; + const debtBeforeUsdCents = readU64(data, offset); + return { + version, + queueId, + index, + isPaid, + recipientWallet, + payoutUsdCents, + debtBeforeUsdCents, + }; +} + +function parseSupportTicketInput(rawValue) { + const clean = String(rawValue || '').trim(); + if (!clean) throw new Error('Введите номер билета.'); + if (/^\d+$/.test(clean)) { + return { queueId: 1, index: BigInt(clean) }; + } + const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/); + if (!match) { + throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.'); + } + return { + queueId: Number(match[1]), + index: BigInt(match[2]), + }; +} + +async function deriveSupportRandomWallet(extraText) { + const solana = await loadSolanaWeb3(); + if (!window.crypto?.getRandomValues || !window.crypto?.subtle) { + throw new Error('Этот браузер не поддерживает безопасную генерацию ключей.'); + } + const entropy = new Uint8Array(32); + window.crypto.getRandomValues(entropy); + const payload = concatBytes( + entropy, + utf8Bytes(extraText || ''), + utf8Bytes(new Date().toISOString()), + utf8Bytes(String(Date.now())), + utf8Bytes(String(performance?.now?.() || 0)), + ); + const seed = new Uint8Array(await window.crypto.subtle.digest('SHA-256', payload)); + const keypair = solana.Keypair.fromSeed(seed); + return { + address: keypair.publicKey.toBase58(), + privateKey32Base58: encodeBase58(seed), + keypair, + generatedAt: new Date().toLocaleString('ru-RU'), + }; +} + +async function loadSupportPaymentsCore(endpoint) { + const solana = await loadSolanaWeb3(); + const rpc = String(endpoint || '').trim(); + const connection = new solana.Connection(rpc, 'confirmed'); + const programId = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID); + const oracleAccount = new solana.PublicKey(SHINE_PAYMENTS_ORACLE_ACCOUNT); + const [configPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.config)], programId); + const [coefPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.coef)], programId); + const [queuesPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.queues)], programId); + const [inflowPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.inflow)], programId); + const [oracleAi, configAi, coefAi, queuesAi] = await Promise.all([ + connection.getAccountInfo(oracleAccount, 'confirmed'), + connection.getAccountInfo(configPda, 'confirmed'), + connection.getAccountInfo(coefPda, 'confirmed'), + connection.getAccountInfo(queuesPda, 'confirmed'), + ]); + if (!oracleAi) throw new Error('Не найден аккаунт оракула SOL/USD.'); + if (!configAi || !coefAi || !queuesAi) throw new Error('PDA программы оплаты ещё не инициализированы.'); + + const parsePrice = (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 { priceNum: num, priceDen: den, publishTime }; + }; + + let configOffset = 0; + const configVersion = configAi.data[configOffset]; + configOffset += 1; + const daoWallet = new solana.PublicKey(configAi.data.slice(configOffset, configOffset + 32)).toBase58(); + configOffset += 32; + const inflowVault = new solana.PublicKey(configAi.data.slice(configOffset, configOffset + 32)).toBase58(); + + return { + connection, + programId, + oracleAccount, + configPda, + coefPda, + queuesPda, + inflowPda, + config: { version: configVersion, daoWallet, inflowVault }, + coef: parsePaymentsCoef(coefAi.data), + queues: parsePaymentsQueues(queuesAi.data), + pyth: parsePrice(oracleAi.data), + }; +} + +function queueStateView(queues, queueId) { + if (queueId === 2) { + return { + ticketsTotal: queues.q2TicketsTotal, + ticketsPaid: queues.q2TicketsPaid, + sumTotalUsdCents: queues.q2SumTotalUsdCents, + sumPaidUsdCents: queues.q2SumPaidUsdCents, + }; + } + if (queueId === 3) { + return { + ticketsTotal: queues.q3TicketsTotal, + ticketsPaid: queues.q3TicketsPaid, + sumTotalUsdCents: queues.q3SumTotalUsdCents, + sumPaidUsdCents: queues.q3SumPaidUsdCents, + }; + } + return { + ticketsTotal: queues.q1TicketsTotal, + ticketsPaid: queues.q1TicketsPaid, + sumTotalUsdCents: queues.q1SumTotalUsdCents, + sumPaidUsdCents: queues.q1SumPaidUsdCents, + }; +} + +function queueSeedFor(queueId) { + if (queueId === 2) return SHINE_PAYMENTS_SEEDS.q2; + if (queueId === 3) return SHINE_PAYMENTS_SEEDS.q3; + return SHINE_PAYMENTS_SEEDS.q1; +} + +function ticketPdaFor(programId, queueId, index) { + const solana = window.solanaWeb3; + return solana.PublicKey.findProgramAddressSync( + [utf8Bytes(queueSeedFor(queueId)), u64ToBytes(index)], + programId, + ); +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -99,6 +429,725 @@ export function render({ navigate }) { arweaveWalletCtx = null; } + function styleSupportInputField(field) { + if (!field) return; + field.style.color = '#111111'; + field.style.webkitTextFillColor = '#111111'; + field.style.caretColor = '#111111'; + field.style.backgroundColor = '#ffffff'; + } + + function renderSupportHub() { + activeModeToken += 1; + clearArweaveSecretsInMemory(); + content.innerHTML = ''; + + const backBtn = createModeBackButton(renderWalletChoice); + + const intro = document.createElement('div'); + intro.className = 'card stack'; + intro.innerHTML = ` +

Поддержать проект Сияние

+

+ Здесь можно купить билет, посмотреть очередь и увидеть, как устроена покупка. + Оплата идет в SOL, а сумма считается по курсу USD/USDT на момент транзакции. + Номер билета и секретный ключ кошелька нужно сохранить отдельно. +

+ `; + + const actions = document.createElement('div'); + actions.className = 'stack'; + actions.innerHTML = ` + + + + `; + + actions.querySelector('#support-buy')?.addEventListener('click', () => { + void renderSupportBuyIntro(); + }); + actions.querySelector('#support-queue')?.addEventListener('click', () => { + void renderSupportQueue(); + }); + actions.querySelector('#support-keygen')?.addEventListener('click', () => { + void renderSupportKeygen(); + }); + + content.append(backBtn, intro, actions); + setStatus('Выберите действие в разделе поддержки.'); + } + + async function renderSupportHelp(backTarget = renderSupportBuyForm) { + const modeToken = ++activeModeToken; + clearArweaveSecretsInMemory(); + content.innerHTML = ''; + + const backBtn = createModeBackButton(backTarget); + const card = document.createElement('div'); + card.className = 'card stack'; + card.innerHTML = ` +

Справка по покупке билета

+

${SUPPORT_TICKET_HELP_TEXT}

+

+ После покупки билет остается в очереди 1. Позже можно открыть экран просмотра и проверить, сколько билетов и сумм уже прошло перед ним. +

+ `; + + content.append(backBtn, card); + if (modeToken === activeModeToken) setStatus('Открыта подробная справка.'); + } + + async function renderSupportKeygen() { + const modeToken = ++activeModeToken; + clearArweaveSecretsInMemory(); + content.innerHTML = ''; + + const backBtn = createModeBackButton(renderSupportHub); + const card = document.createElement('div'); + card.className = 'card stack'; + card.innerHTML = ` +

Сгенерировать новую пару ключей

+

+ Генерация использует безопасный рандом браузера, а ваш дополнительный текст и текущее время добавляются как примесь. + Вводить текст необязательно: даже без него ключи остаются случайными. +

+ `; + + const saltLabel = document.createElement('label'); + saltLabel.className = 'meta-muted'; + saltLabel.setAttribute('for', 'support-key-salt'); + saltLabel.textContent = 'Дополнительная соль (необязательно)'; + + const saltInput = document.createElement('textarea'); + saltInput.id = 'support-key-salt'; + saltInput.rows = 3; + saltInput.placeholder = 'Можно оставить пустым или добавить любой текст как дополнительную примесь'; + saltInput.spellcheck = false; + styleSupportInputField(saltInput); + + const timeLabel = document.createElement('p'); + timeLabel.className = 'meta-muted'; + timeLabel.textContent = 'Время генерации появится после нажатия кнопки.'; + + const generatedPublicLabel = document.createElement('label'); + generatedPublicLabel.className = 'meta-muted'; + generatedPublicLabel.setAttribute('for', 'support-generated-public'); + generatedPublicLabel.textContent = 'Публичный ключ'; + + const generatedPublicInput = document.createElement('input'); + generatedPublicInput.id = 'support-generated-public'; + generatedPublicInput.type = 'text'; + generatedPublicInput.readOnly = true; + generatedPublicInput.placeholder = 'Появится после генерации'; + styleSupportInputField(generatedPublicInput); + + const generatedSecretLabel = document.createElement('label'); + generatedSecretLabel.className = 'meta-muted'; + generatedSecretLabel.setAttribute('for', 'support-generated-secret'); + generatedSecretLabel.textContent = 'Секретный ключ (Base58, показывается один раз)'; + + const generatedSecretInput = document.createElement('textarea'); + generatedSecretInput.id = 'support-generated-secret'; + generatedSecretInput.rows = 4; + generatedSecretInput.readOnly = true; + generatedSecretInput.placeholder = 'Появится после генерации'; + generatedSecretInput.spellcheck = false; + styleSupportInputField(generatedSecretInput); + + const generatedAddressNote = document.createElement('p'); + generatedAddressNote.className = 'meta-muted'; + generatedAddressNote.style.margin = '0'; + generatedAddressNote.textContent = 'Secret key не сохраняется на сервере и не пишется в localStorage.'; + + const actions = document.createElement('div'); + actions.className = 'stack'; + actions.innerHTML = ` + +
+ + +
+ + `; + + const generateBtn = actions.querySelector('#support-generate'); + const copyPublicBtn = actions.querySelector('#support-copy-public'); + const copySecretBtn = actions.querySelector('#support-copy-secret'); + const downloadBtn = actions.querySelector('#support-download'); + let generatedPair = null; + + const clearGenerated = () => { + generatedPair = null; + generatedPublicInput.value = ''; + generatedSecretInput.value = ''; + timeLabel.textContent = 'Время генерации появится после нажатия кнопки.'; + }; + + clearGenerated(); + + generateBtn?.addEventListener('click', async () => { + generateBtn.disabled = true; + try { + generatedPair = await deriveSupportRandomWallet(saltInput.value); + if (modeToken !== activeModeToken) return; + generatedPublicInput.value = generatedPair.address; + generatedSecretInput.value = generatedPair.privateKey32Base58; + timeLabel.textContent = `Сгенерировано: ${generatedPair.generatedAt}. Использован безопасный рандом браузера, текст и время добавлены как примесь.`; + setStatus('Новая пара ключей сгенерирована. Секретный ключ показан на экране один раз.'); + } catch (error) { + if (modeToken !== activeModeToken) return; + setStatus(`Не удалось сгенерировать пару ключей: ${error?.message || 'unknown'}`); + } finally { + generateBtn.disabled = false; + } + }); + + copyPublicBtn?.addEventListener('click', async () => { + const value = String(generatedPublicInput.value || '').trim(); + if (!value) { + setStatus('Сначала сгенерируйте пару ключей.'); + return; + } + try { + await navigator.clipboard.writeText(value); + setStatus('Public key скопирован.'); + } catch { + setStatus('Не удалось скопировать public key.'); + } + }); + + copySecretBtn?.addEventListener('click', async () => { + const value = String(generatedSecretInput.value || '').trim(); + if (!value) { + setStatus('Сначала сгенерируйте пару ключей.'); + return; + } + try { + await navigator.clipboard.writeText(value); + setStatus('Secret key скопирован.'); + } catch { + setStatus('Не удалось скопировать secret key.'); + } + }); + + downloadBtn?.addEventListener('click', () => { + const publicKey = String(generatedPublicInput.value || '').trim(); + const secretKey = String(generatedSecretInput.value || '').trim(); + if (!publicKey || !secretKey) { + setStatus('Сначала сгенерируйте пару ключей.'); + return; + } + const payload = [ + `Публичный ключ: ${publicKey}`, + `Секретный ключ: ${secretKey}`, + `Время: ${timeLabel.textContent || ''}`, + ].join('\n'); + const blob = new Blob([payload], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `shine-keypair-${Date.now()}.txt`; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + setStatus('Файл с ключами скачан.'); + }); + + content.append( + backBtn, + card, + saltLabel, + saltInput, + timeLabel, + generatedPublicLabel, + generatedPublicInput, + generatedSecretLabel, + generatedSecretInput, + generatedAddressNote, + actions, + ); + setStatus('Генератор ключей готов. Дополнительная соль необязательна.'); + } + + async function renderSupportQueue() { + const modeToken = ++activeModeToken; + clearArweaveSecretsInMemory(); + content.innerHTML = ''; + + const backBtn = createModeBackButton(renderSupportHub); + const card = document.createElement('div'); + card.className = 'card stack'; + card.innerHTML = ` +

Посмотреть очередь билета

+

+ Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате 2-15 или 3 8. + Экран покажет, сколько билетов перед ним уже стоит, сколько уже выплачено и кому принадлежит билет. +

+ `; + + const inputLabel = document.createElement('label'); + inputLabel.className = 'meta-muted'; + inputLabel.setAttribute('for', 'support-ticket-query'); + inputLabel.textContent = 'Номер билета'; + + const queryInput = document.createElement('input'); + queryInput.id = 'support-ticket-query'; + queryInput.type = 'text'; + queryInput.placeholder = 'Например: 12, 2-5 или 3 8'; + queryInput.autocomplete = 'off'; + queryInput.spellcheck = false; + styleSupportInputField(queryInput); + + const actions = document.createElement('div'); + actions.className = 'row'; + actions.innerHTML = ` + + + `; + + const result = document.createElement('div'); + result.className = 'card stack'; + result.innerHTML = ` +

Введите номер билета и нажмите кнопку.

+ `; + + let lastCoreState = null; + + const renderTicketInfo = async () => { + const raw = String(queryInput.value || '').trim(); + if (!raw) { + result.innerHTML = `

Введите номер билета.

`; + return; + } + try { + const parsed = parseSupportTicketInput(raw); + const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer); + lastCoreState = core; + const solana = await loadSolanaWeb3(); + const ticketPdaSeed = queueSeedFor(parsed.queueId); + const ticketIndexBytes = u64ToBytes(parsed.index); + const [ticketPda] = solana.PublicKey.findProgramAddressSync( + [utf8Bytes(ticketPdaSeed), ticketIndexBytes], + core.programId, + ); + const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed'); + if (!ticketInfo) { + result.innerHTML = ` +

Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.

+ `; + return; + } + const ticket = parsePaymentsTicket(ticketInfo.data); + const queueState = queueStateView(core.queues, parsed.queueId); + const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n; + const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore; + const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n; + result.innerHTML = ` +
Билет: ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}
+
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
+
Получатель: ${ticket.recipientWallet}
+
До него в очереди: ${ticketsBefore.toString()} билетов
+
Из них уже выплачено: ${paidBefore.toString()} билетов
+
Ещё осталось до него: ${remainingBefore.toString()} билетов
+
Уже выплачено по сумме в очереди: ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD
+
Сумма этого билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
+
Накоплено до этого билета: ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него
+ `; + setStatus(`Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`); + } catch (error) { + result.innerHTML = `

${String(error?.message || 'Не удалось загрузить билет')}

`; + setStatus(`Не удалось посмотреть билет: ${error?.message || 'unknown'}`); + } + }; + + actions.querySelector('#support-ticket-load')?.addEventListener('click', () => { + void renderTicketInfo(); + }); + actions.querySelector('#support-ticket-reset')?.addEventListener('click', () => { + queryInput.value = ''; + result.innerHTML = `

Введите номер билета и нажмите кнопку.

`; + setStatus('Поле билета очищено.'); + }); + + content.append(backBtn, card, inputLabel, queryInput, actions, result); + setStatus('Просмотр билета готов. Введите номер в нужном формате.'); + } + + async function renderSupportBuyIntro() { + const modeToken = ++activeModeToken; + clearArweaveSecretsInMemory(); + content.innerHTML = ''; + + const backBtn = createModeBackButton(renderSupportHub); + const introCard = document.createElement('div'); + introCard.className = 'card stack'; + introCard.innerHTML = ` +

Купить билет

+

+ Сначала показываем условия и текущий лимит. После этого можно перейти к покупке и ввести сумму в долларах. + Оплата идет в SOL, а расчет строится по курсу USD/USDT на момент транзакции. +

+ `; + + const stateCard = document.createElement('div'); + stateCard.className = 'card stack'; + stateCard.innerHTML = `

Загрузка условий...

`; + + const actions = document.createElement('div'); + actions.className = 'stack'; + actions.innerHTML = ` + + + `; + + const nextBtn = actions.querySelector('#support-buy-next'); + const helpBtn = actions.querySelector('#support-buy-help'); + nextBtn.disabled = true; + + let currentCore = null; + try { + currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer); + if (modeToken !== activeModeToken) return; + const queue = queueStateView(currentCore.queues, 1); + const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents + ? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents + : 0n; + stateCard.innerHTML = ` +
Коэффициент: ${formatPpmCoefText(currentCore.coef.coefPpm)}
+
Лимит текущего коэффициента: ${formatUsdCentsText(currentCore.coef.limitUsdCents)} USD
+
Уже куплено в очереди 1: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
+
Осталось купить по текущему коэффициенту: ${formatUsdCentsText(remainingUsdCents)} USD
+
Сколько билетов уже в очереди: ${queue.ticketsTotal.toString()}
+
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
+
Курс: 1 SOL = ${formatPythSolUsdText(currentCore.pyth)} USD
+
+ После заполнения лимита будет новый лимит, но уже с более низким коэффициентом. +
+ `; + nextBtn.disabled = false; + setStatus('Условия покупки загружены. Можно переходить к форме покупки.'); + } catch (error) { + if (modeToken !== activeModeToken) return; + stateCard.innerHTML = `

${String(error?.message || 'Не удалось загрузить состояние')}

`; + nextBtn.disabled = true; + setStatus(`Не удалось загрузить условия покупки: ${error?.message || 'unknown'}`); + } + + nextBtn?.addEventListener('click', () => { + void renderSupportBuyForm(); + }); + helpBtn?.addEventListener('click', () => { + void renderSupportHelp(renderSupportBuyIntro); + }); + + content.append(backBtn, introCard, stateCard, actions); + } + + async function renderSupportBuyForm() { + const modeToken = ++activeModeToken; + clearArweaveSecretsInMemory(); + content.innerHTML = ''; + + const backBtn = createModeBackButton(renderSupportBuyIntro); + const helpCard = document.createElement('div'); + helpCard.className = 'card stack'; + helpCard.innerHTML = ` +

Купить билет на сумму в долларах

+

+ Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата. + Покупка подписывается вашим client key и отклоняется, если курс уходит дальше допустимого порога. +

+ `; + + const stateCard = document.createElement('div'); + stateCard.className = 'card stack'; + stateCard.innerHTML = ` +

Текущее состояние покупки загружается...

+ `; + + const amountLabel = document.createElement('label'); + amountLabel.className = 'meta-muted'; + amountLabel.setAttribute('for', 'support-buy-amount'); + amountLabel.textContent = 'Сумма билета в долларах'; + + const amountInput = document.createElement('input'); + amountInput.id = 'support-buy-amount'; + amountInput.type = 'text'; + amountInput.value = '20'; + amountInput.inputMode = 'decimal'; + amountInput.autocomplete = 'off'; + styleSupportInputField(amountInput); + + const recipientWrap = document.createElement('div'); + recipientWrap.className = 'stack'; + const recipientInput = document.createElement('input'); + recipientInput.id = 'support-buy-recipient'; + recipientInput.type = 'text'; + recipientInput.placeholder = 'Можно оставить пустым'; + recipientInput.autocomplete = 'off'; + recipientInput.spellcheck = false; + styleSupportInputField(recipientInput); + const recipientLabel = document.createElement('label'); + recipientLabel.className = 'meta-muted'; + recipientLabel.setAttribute('for', 'support-buy-recipient'); + recipientLabel.textContent = 'Адрес кошелька для билета и выплаты'; + recipientWrap.append(recipientLabel, recipientInput); + + const sameWalletRow = document.createElement('label'); + sameWalletRow.className = 'row'; + sameWalletRow.style.gap = '10px'; + sameWalletRow.style.alignItems = 'center'; + sameWalletRow.innerHTML = ` + + На тот же кошелёк, с которого покупаем + `; + + const quoteCard = document.createElement('div'); + quoteCard.className = 'card stack'; + quoteCard.innerHTML = `

Расчет появится после загрузки курса.

`; + + const purchaseResultCard = document.createElement('div'); + purchaseResultCard.className = 'card stack'; + purchaseResultCard.innerHTML = ` +

После покупки здесь появится номер билета и краткий итог.

+ `; + + const actions = document.createElement('div'); + actions.className = 'stack'; + actions.innerHTML = ` +
+ + +
+ + `; + + const buyBtn = actions.querySelector('#support-buy-submit'); + const refreshBtn = actions.querySelector('#support-buy-refresh'); + const helpBtn = actions.querySelector('#support-buy-help'); + const sameWalletCheckbox = sameWalletRow.querySelector('#support-buy-same-wallet'); + + let walletCtx = null; + let walletAddress = ''; + let currentCore = null; + + const setRecipientFromWallet = () => { + if (sameWalletCheckbox?.checked && walletAddress) { + recipientInput.value = walletAddress; + recipientInput.disabled = true; + } else { + recipientInput.disabled = false; + } + styleSupportInputField(recipientInput); + }; + + const updateQuote = () => { + if (!currentCore) return; + const queue = queueStateView(currentCore.queues, 1); + const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents + ? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents + : 0n; + const currentCoef = formatPpmCoefText(currentCore.coef.coefPpm); + const usdRaw = String(amountInput.value || '').trim().replace(',', '.'); + let amountUsdCents = 0n; + let amountSol = '—'; + let maxPaySol = '—'; + let canBuy = true; + try { + const asNumber = Number(usdRaw); + if (!Number.isFinite(asNumber) || asNumber <= 0) throw new Error('bad amount'); + amountUsdCents = BigInt(Math.round(asNumber * 100)); + const payLamports = (amountUsdCents * LAMPORTS_PER_SOL * currentCore.pyth.priceDen + currentCore.pyth.priceNum - 1n) / currentCore.pyth.priceNum; + const maxPayLamports = (payLamports * 103n + 99n) / 100n; + amountSol = formatLamportsSolText(payLamports, 9); + maxPaySol = formatLamportsSolText(maxPayLamports, 9); + if (amountUsdCents > remainingUsdCents) canBuy = false; + } catch { + canBuy = false; + } + + quoteCard.innerHTML = ` +
Коэффициент сейчас: ${currentCoef}
+
В очереди перед вами уже куплено: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
+
Осталось до лимита текущего коэффициента: ${formatUsdCentsText(remainingUsdCents)} USD
+
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
+
Примерная цена: ${amountSol} SOL
+
Максимум при допуске 3%: ${maxPaySol} SOL
+
${canBuy ? 'Покупка укладывается в текущий лимит.' : 'Сумма сейчас не укладывается в текущий лимит или введена некорректно.'}
+
+ Если текущий лимит заполнится, откроется новый лимит с более низким коэффициентом. + Покупка идет в SOL, но считается по курсу USD/USDT на момент транзакции. +
+ `; + const hasRecipient = sameWalletCheckbox?.checked + ? Boolean(walletAddress) + : Boolean(String(recipientInput.value || '').trim()); + buyBtn.disabled = !canBuy || !hasRecipient; + }; + + const refreshCore = async () => { + refreshBtn.disabled = true; + try { + currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer); + if (modeToken !== activeModeToken) return; + const queue = queueStateView(currentCore.queues, 1); + const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents + ? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents + : 0n; + stateCard.innerHTML = ` +
Коэффициент: ${formatPpmCoefText(currentCore.coef.coefPpm)}
+
Лимит текущего коэффициента: ${formatUsdCentsText(currentCore.coef.limitUsdCents)} USD
+
Уже куплено в очереди 1: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
+
Осталось купить по текущему коэффициенту: ${formatUsdCentsText(remainingUsdCents)} USD
+
Сколько билетов уже в очереди: ${queue.ticketsTotal.toString()}
+
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
+
Курс: 1 SOL = ${formatPythSolUsdText(currentCore.pyth)} USD
+
+ После заполнения лимита будет новый лимит, но уже с более низким коэффициентом. +
+ `; + setRecipientFromWallet(); + updateQuote(); + setStatus('Условия покупки обновлены.'); + } catch (error) { + if (modeToken !== activeModeToken) return; + stateCard.innerHTML = `

${String(error?.message || 'Не удалось загрузить состояние')}

`; + quoteCard.innerHTML = `

${String(error?.message || 'Не удалось рассчитать цену')}

`; + setStatus(`Не удалось загрузить условия покупки: ${error?.message || 'unknown'}`); + } finally { + refreshBtn.disabled = false; + } + }; + + amountInput.addEventListener('input', updateQuote); + recipientInput.addEventListener('input', updateQuote); + sameWalletCheckbox?.addEventListener('change', () => { + setRecipientFromWallet(); + updateQuote(); + }); + + helpBtn?.addEventListener('click', () => { + void renderSupportHelp(renderSupportBuyForm); + }); + + refreshBtn?.addEventListener('click', () => { + void refreshCore(); + }); + + buyBtn?.addEventListener('click', async () => { + buyBtn.disabled = true; + try { + if (!currentCore) { + currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer); + } + if (!walletCtx) { + walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow()); + walletAddress = walletCtx.address; + } + setRecipientFromWallet(); + const queue = queueStateView(currentCore.queues, 1); + const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents + ? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents + : 0n; + const amountUsd = String(amountInput.value || '').trim().replace(',', '.'); + const amountUsdNumber = Number(amountUsd); + if (!Number.isFinite(amountUsdNumber) || amountUsdNumber <= 0) { + throw new Error('Введите корректную сумму в долларах.'); + } + const amountUsdCents = BigInt(Math.round(amountUsdNumber * 100)); + if (amountUsdCents > remainingUsdCents) { + throw new Error('Сумма превышает остаток текущего лимита. Уменьшите сумму или дождитесь нового лимита.'); + } + const recipientWallet = String(recipientInput.value || '').trim() || walletAddress; + if (!recipientWallet) throw new Error('Не указан кошелёк получателя.'); + const solana = await loadSolanaWeb3(); + const recipientPubkey = new solana.PublicKey(recipientWallet); + const payLamports = (amountUsdCents * LAMPORTS_PER_SOL * currentCore.pyth.priceDen + currentCore.pyth.priceNum - 1n) / currentCore.pyth.priceNum; + const maxPayLamports = (payLamports * 103n + 99n) / 100n; + const nextIndex = queue.ticketsTotal + 1n; + const [ticketPda] = solana.PublicKey.findProgramAddressSync( + [utf8Bytes(SHINE_PAYMENTS_SEEDS.q1), u64ToBytes(nextIndex)], + currentCore.programId, + ); + const data = concatBytes( + new Uint8Array([5]), + u64ToBytes(amountUsdCents), + u64ToBytes(maxPayLamports), + recipientPubkey.toBytes(), + ); + const ix = new solana.TransactionInstruction({ + programId: currentCore.programId, + keys: [ + { pubkey: walletCtx.keypair.publicKey, isSigner: true, isWritable: true }, + { pubkey: currentCore.configPda, isSigner: false, isWritable: true }, + { pubkey: currentCore.coefPda, isSigner: false, isWritable: true }, + { pubkey: currentCore.queuesPda, isSigner: false, isWritable: true }, + { pubkey: ticketPda, isSigner: false, isWritable: true }, + { pubkey: new solana.PublicKey(currentCore.config.daoWallet), isSigner: false, isWritable: true }, + { pubkey: currentCore.oracleAccount, isSigner: false, isWritable: false }, + { pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false }, + ], + data, + }); + const connection = new solana.Connection(String(state.entrySettings.solanaServer || '').trim(), 'confirmed'); + const tx = new solana.Transaction().add(ix); + tx.feePayer = walletCtx.keypair.publicKey; + const bh = await connection.getLatestBlockhash('confirmed'); + tx.recentBlockhash = bh.blockhash; + tx.partialSign(walletCtx.keypair); + const signature = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false }); + await connection.confirmTransaction( + { signature, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, + 'confirmed', + ); + const ticketInfo = await connection.getAccountInfo(ticketPda, 'confirmed'); + const ticket = ticketInfo?.data + ? parsePaymentsTicket(ticketInfo.data) + : { index: nextIndex, queueId: 1, recipientWallet, payoutUsdCents: amountUsdCents }; + if (modeToken !== activeModeToken) return; + setStatus(`Билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()} куплен. Сохраните номер билета и secret key используемого кошелька.`); + purchaseResultCard.innerHTML = ` +
Покупка завершена: билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()}
+
Получатель: ${ticket.recipientWallet}
+
Сумма билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
+
До него было в очереди: ${ticket.index > 0n ? (ticket.index - 1n).toString() : '0'} билетов
+
Транзакция: ${signature}
+ `; + await refreshCore(); + } catch (error) { + if (modeToken !== activeModeToken) return; + setStatus(`Не удалось купить билет: ${error?.message || 'unknown'}`); + } finally { + buyBtn.disabled = false; + } + }); + + content.append( + backBtn, + helpCard, + stateCard, + amountLabel, + amountInput, + recipientWrap, + sameWalletRow, + quoteCard, + purchaseResultCard, + actions, + ); + + if (!walletCtx) { + try { + walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow()); + walletAddress = walletCtx.address; + if (modeToken !== activeModeToken) return; + setRecipientFromWallet(); + } catch (error) { + setStatus(`Не удалось загрузить client.key: ${error?.message || 'unknown'}`); + } + } + + await refreshCore(); + } + function renderWalletChoice() { activeModeToken += 1; clearArweaveSecretsInMemory(); @@ -130,12 +1179,20 @@ export function render({ navigate }) { const shineBchBtn = document.createElement('button'); shineBchBtn.className = 'primary-btn'; shineBchBtn.style.width = '100%'; - shineBchBtn.textContent = 'Блокчейн Сияния'; + shineBchBtn.textContent = 'Предоплаченное место Сияния'; shineBchBtn.addEventListener('click', () => { void renderShineBlockchainWallet(); }); - card.append(solanaBtn, arweaveBtn, shineBchBtn); + const supportBtn = document.createElement('button'); + supportBtn.className = 'primary-btn'; + supportBtn.style.width = '100%'; + supportBtn.textContent = 'Поддержать проект Сияние'; + supportBtn.addEventListener('click', () => { + void renderSupportHub(); + }); + + card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn); content.append(card); setStatus('Выберите тип кошелька.'); } @@ -148,6 +1205,9 @@ export function render({ navigate }) { const backBtn = createModeBackButton(renderWalletChoice); const card = document.createElement('div'); card.className = 'card stack'; + card.innerHTML = ` +

Предоплаченное место в блокчейне Сияния

+ `; const limitLabel = document.createElement('p'); limitLabel.className = 'meta-muted'; @@ -198,18 +1258,6 @@ export function render({ navigate }) { serverLastHashLabel.style.fontSize = '11px'; serverLastHashLabel.textContent = 'Hash: —'; - const solanaTitle = document.createElement('h3'); - solanaTitle.style.margin = '14px 0 0'; - solanaTitle.textContent = 'Закреплено в Solana'; - const solanaLastLabel = document.createElement('p'); - solanaLastLabel.className = 'meta-muted'; - solanaLastLabel.textContent = 'Крайний блок: —'; - const solanaLastHashLabel = document.createElement('p'); - solanaLastHashLabel.className = 'meta-muted'; - solanaLastHashLabel.style.wordBreak = 'break-all'; - solanaLastHashLabel.style.fontSize = '11px'; - solanaLastHashLabel.textContent = 'Hash: —'; - card.append( limitLabel, limitValue, @@ -224,9 +1272,6 @@ export function render({ navigate }) { serverSizeLabel, serverLastLabel, serverLastHashLabel, - solanaTitle, - solanaLastLabel, - solanaLastHashLabel, ); const actions = document.createElement('div'); @@ -468,203 +1513,6 @@ export function render({ navigate }) { const sendBtn = actions.querySelector('#send-sol'); const topupBtn = actions.querySelector('#topup-sol'); - const generatedCard = document.createElement('div'); - generatedCard.className = 'card stack'; - generatedCard.innerHTML = ` -

Создание нового кошелька Solana

-

Введите приватный ключ Base58 (32 байта) или сгенерируйте случайный.

- `; - - const privateLabel = document.createElement('label'); - privateLabel.className = 'meta-muted'; - privateLabel.textContent = 'Приватный ключ (Base58, 32 байта)'; - privateLabel.setAttribute('for', 'solana-private-base58-input'); - - const privateInput = document.createElement('input'); - privateInput.id = 'solana-private-base58-input'; - privateInput.type = 'text'; - privateInput.placeholder = 'Введите приватный ключ Base58'; - privateInput.maxLength = SOLANA_PRIVATE_BASE58_MAX_LEN; - privateInput.autocomplete = 'off'; - privateInput.spellcheck = false; - - const privateState = document.createElement('p'); - privateState.className = 'meta-muted'; - privateState.textContent = 'Ожидается Base58-строка приватного ключа.'; - - const generatedPublicLabel = document.createElement('label'); - generatedPublicLabel.className = 'meta-muted'; - generatedPublicLabel.textContent = 'Публичный ключ (Base58)'; - generatedPublicLabel.setAttribute('for', 'solana-generated-public-key'); - - const generatedPublicInput = document.createElement('input'); - generatedPublicInput.id = 'solana-generated-public-key'; - generatedPublicInput.type = 'text'; - generatedPublicInput.readOnly = true; - generatedPublicInput.placeholder = 'Будет сгенерирован после нажатия кнопки'; - - const generatedPrivateLabel = document.createElement('label'); - generatedPrivateLabel.className = 'meta-muted'; - generatedPrivateLabel.textContent = 'Сгенерированный приватный ключ (Base58)'; - generatedPrivateLabel.setAttribute('for', 'solana-generated-private-key'); - - const generatedPrivateInput = document.createElement('input'); - generatedPrivateInput.id = 'solana-generated-private-key'; - generatedPrivateInput.type = 'text'; - generatedPrivateInput.readOnly = true; - generatedPrivateInput.placeholder = 'Появится после генерации'; - - const generationActions = document.createElement('div'); - generationActions.className = 'row'; - generationActions.innerHTML = ` - - - `; - - const copyGeneratedActions = document.createElement('div'); - copyGeneratedActions.className = 'row'; - copyGeneratedActions.innerHTML = ` - - - `; - - generatedCard.append( - privateLabel, - privateInput, - privateState, - generationActions, - generatedPrivateLabel, - generatedPrivateInput, - generatedPublicLabel, - generatedPublicInput, - copyGeneratedActions, - ); - - const randomGenerateBtn = generationActions.querySelector('#generate-random-solana'); - const fromPrivateGenerateBtn = generationActions.querySelector('#generate-from-private-solana'); - const copyGeneratedPrivateBtn = copyGeneratedActions.querySelector('#copy-generated-private-solana'); - const copyGeneratedPublicBtn = copyGeneratedActions.querySelector('#copy-generated-public-solana'); - - const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; - const validatePrivateInput = () => { - const value = String(privateInput.value || '').trim(); - if (!value) { - privateState.textContent = 'Ожидается Base58-строка приватного ключа.'; - return false; - } - if (!BASE58_RE.test(value)) { - privateState.textContent = 'Недопустимый формат: используйте только Base58.'; - return false; - } - if (value.length > SOLANA_PRIVATE_BASE58_MAX_LEN) { - privateState.textContent = 'Слишком длинное значение.'; - return false; - } - try { - const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; - let num = 0n; - for (const c of value) { - num = num * 58n + BigInt(alphabet.indexOf(c)); - } - let hex = num.toString(16); - if (hex.length % 2) hex = `0${hex}`; - const decoded = hex ? hex.match(/.{1,2}/g)?.map((h) => parseInt(h, 16)) || [] : []; - let leadingZeros = 0; - while (leadingZeros < value.length && value[leadingZeros] === '1') leadingZeros += 1; - const byteLen = leadingZeros + decoded.length; - if (byteLen < 32) { - privateState.textContent = 'Слишком короткое значение: нужно 32 байта.'; - return false; - } - if (byteLen > 32) { - privateState.textContent = 'Слишком длинное значение: нужно ровно 32 байта.'; - return false; - } - } catch { - privateState.textContent = 'Ошибка декодирования Base58.'; - return false; - } - privateState.textContent = 'Подходит'; - return true; - }; - - privateInput.addEventListener('input', () => { - validatePrivateInput(); - }); - - const setGenerationDisabled = (disabled) => { - randomGenerateBtn.disabled = disabled; - fromPrivateGenerateBtn.disabled = disabled; - copyGeneratedPrivateBtn.disabled = disabled; - copyGeneratedPublicBtn.disabled = disabled; - }; - - randomGenerateBtn.addEventListener('click', async () => { - setGenerationDisabled(true); - try { - const generated = await createRandomSolanaWallet(); - if (modeToken !== activeModeToken) return; - generatedPrivateInput.value = generated.privateKey32Base58; - generatedPublicInput.value = generated.address; - privateState.textContent = 'Случайный кошелёк создан.'; - setStatus('Случайный кошелёк Solana успешно сгенерирован.'); - } catch (error) { - if (modeToken !== activeModeToken) return; - setStatus(`Ошибка генерации случайного кошелька: ${error?.message || 'unknown'}`); - } finally { - setGenerationDisabled(false); - } - }); - - fromPrivateGenerateBtn.addEventListener('click', async () => { - if (!validatePrivateInput()) { - setStatus('Исправьте приватный ключ перед генерацией.'); - return; - } - setGenerationDisabled(true); - try { - const generated = await createSolanaWalletFromPrivateBase58(privateInput.value); - if (modeToken !== activeModeToken) return; - generatedPrivateInput.value = generated.privateKey32Base58; - generatedPublicInput.value = generated.address; - privateState.textContent = 'Подходит'; - setStatus('Публичный ключ сгенерирован из введённого приватного ключа.'); - } catch (error) { - if (modeToken !== activeModeToken) return; - setStatus(`Ошибка генерации из приватного ключа: ${error?.message || 'unknown'}`); - } finally { - setGenerationDisabled(false); - } - }); - - copyGeneratedPrivateBtn.addEventListener('click', async () => { - const value = String(generatedPrivateInput.value || '').trim(); - if (!value) { - setStatus('Сначала сгенерируйте приватный ключ.'); - return; - } - try { - await navigator.clipboard.writeText(value); - setStatus('Приватный ключ скопирован.'); - } catch { - setStatus('Не удалось скопировать приватный ключ в этом браузере.'); - } - }); - - copyGeneratedPublicBtn.addEventListener('click', async () => { - const value = String(generatedPublicInput.value || '').trim(); - if (!value) { - setStatus('Сначала сгенерируйте публичный ключ.'); - return; - } - try { - await navigator.clipboard.writeText(value); - setStatus('Публичный ключ скопирован.'); - } catch { - setStatus('Не удалось скопировать публичный ключ в этом браузере.'); - } - }); - const refreshBalance = async () => { if (!walletAddress) { setStatus('Кошелёк не инициализирован.'); @@ -740,7 +1588,7 @@ export function render({ navigate }) { window.location.assign(getTopupSiteUrl(walletAddress)); }); - content.append(backBtn, card, actions, generatedCard); + content.append(backBtn, card, actions); setStatus('Инициализация client.key...'); try { diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index aa83be5..637dc03 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -152,6 +152,10 @@ export function getRoute() { return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } }; } + if (pageId === 'queue') { + return { pageId, params: {} }; + } + return { pageId, params: {} }; } @@ -178,6 +182,7 @@ export function resolveToolbarActive(pageId) { pageId === 'developer-settings-view' || pageId === 'server-settings-view' || pageId === 'tools-settings-view' || + pageId === 'remote-addblock-session-view' || pageId === 'device-view' || pageId === 'connect-device-view' || pageId === 'device-pairing-view' || diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index d02254f..029201c 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -4,16 +4,26 @@ import { bytesToBase64, deriveEd25519FromMasterSecret, deriveMasterSecretFromPassword, + ed25519PublicToX25519Public, + ed25519SeedToX25519Private, exportEd25519PublicKeyB64, exportPkcs8B64, generateEd25519Pair, + extractEd25519SeedFromPkcs8B64, + hkdfSha256, importPkcs8Ed25519, publicKeyB64FromPkcs8Ed25519, randomBase64, + randomBytes, sha256Bytes, signBytes, signBase64, utf8Bytes, + x25519PublicFromPrivate, + x25519RandomPrivateKey, + x25519SharedSecret, + encryptBytesAesGcm, + decryptBytesAesGcm, } from './crypto-utils.js'; import { channelNameErrorText, @@ -56,6 +66,11 @@ const CHANNEL_TYPE_GROUP = 200; const CHANNEL_TYPE_VERSION_DEFAULT = 1; const SESSION_TYPE_CLIENT = 1; const SESSION_TYPE_WALLET = 50; +const SESSION_TYPE_HOMESERVER = 100; +const SIGNAL_TARGET_SINGLE = 'single_session'; +const SIGNAL_TARGET_ALL = 'all_sessions'; +const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request'; +const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result'; const CONNECTION_SUBTYPES = Object.freeze({ // Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11. @@ -108,6 +123,10 @@ function opError(op, response) { return error; } +function createSignalRequestId(prefix = 'signal') { + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + function makeClientInfo() { const ua = navigator.userAgent || 'unknown'; return ua.slice(0, 50); @@ -137,6 +156,11 @@ function normalizeHex32(value, fallback = ZERO64) { return raw; } +async function sha256Base64FromText(text) { + const digest = await sha256Bytes(utf8Bytes(String(text || ''))); + return bytesToBase64(digest).replace(/=+$/g, ''); +} + function concatBytes(...chunks) { const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const out = new Uint8Array(total); @@ -201,14 +225,23 @@ function uint8Bytes(value) { } const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM'); -const DM2_PREFIX = utf8Bytes('SHiNE_dm2'); -const DM2_TYPE_INCOMING = 1; -const DM2_TYPE_OUTGOING_COPY = 2; -const DM2_TYPE_READ_INCOMING = 3; -const DM2_TYPE_READ_OUTGOING_COPY = 4; +const DM_TYPE_INCOMING = 1; +const DM_TYPE_OUTGOING_COPY = 2; +const DM_TYPE_READ_INCOMING = 3; +const DM_TYPE_READ_OUTGOING_COPY = 4; +const DM_TYPE_MESSAGE_DELETED_BY_SENDER = 5; +const DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT = 6; +const DM_TYPE_CONVERSATION_DELETED_BY_SENDER = 7; +const DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT = 8; const DM_FORMAT_VERSION_MAJOR = 1; const DM_FORMAT_VERSION_MINOR = 0; const DM_MAX_ENCRYPTED_BODY_BYTES = 16384; +const DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1; +const DM_CRYPTO_VERSION_1_0 = 0; +const DM_HKDF_INFO = utf8Bytes('SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM'); +const DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT = 'unsupported_format'; +const DM_DECRYPT_ERROR_DECRYPT_FAILED = 'decrypt_failed'; +const DM_DECRYPT_ERROR_CORRUPTED = 'corrupted'; function ensureAsciiBytes(value, field, min = 1, max = 60) { const text = String(value || '').trim(); @@ -223,15 +256,15 @@ function ensureAsciiBytes(value, field, min = 1, max = 60) { return bytes; } -function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) { +function dmBaseKey({ toLogin, fromLogin, timeMs, nonce }) { return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`; } -function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) { - return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`; +function dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) { + return `${dmBaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`; } -function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) { +function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce }) { const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin'); const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin'); return concatBytes( @@ -239,10 +272,111 @@ function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, ref uint8Bytes(fromBytes.length), fromBytes, uint64Bytes(refTimeMs), uint32Bytes(refNonce), - uint16Bytes(refType), ); } +function buildDmEncryptedBodyBytes({ plainBytes, recipientClientKeyB64 }) { + return buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 }); +} + +async function buildDmEncryptedBodyBytesAsync({ plainBytes, recipientClientKeyB64 }) { + const recipientEd25519Pub = base64ToBytes(String(recipientClientKeyB64 || '').trim()); + if (recipientEd25519Pub.length !== 32) { + throw new Error('Некорректный clientKey получателя'); + } + const recipientX25519Pub = ed25519PublicToX25519Public(recipientEd25519Pub); + const ephemeralPriv = x25519RandomPrivateKey(); + const ephemeralPub = x25519PublicFromPrivate(ephemeralPriv); + const sharedSecret = x25519SharedSecret(ephemeralPriv, recipientX25519Pub); + const salt = concatBytes(ephemeralPub, recipientX25519Pub); + const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32); + const iv = randomBytes(12); + const cipherText = await encryptBytesAesGcm(plainBytes, aesKeyBytes, iv); + return concatBytes( + uint8Bytes(DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM), + uint8Bytes(DM_CRYPTO_VERSION_1_0), + uint8Bytes(ephemeralPub.length), + ephemeralPub, + uint8Bytes(iv.length), + iv, + uint32Bytes(cipherText.length), + cipherText, + ); +} + +function parseEncryptedDmBodyBytes(payloadBytes) { + if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_ENCRYPTED_BODY'); + let o = 0; + const read = (n) => { + if (o + n > payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY'); + const out = payloadBytes.slice(o, o + n); + o += n; + return out; + }; + const readU8 = () => read(1)[0]; + const cryptoMethod = readU8(); + const cryptoVersion = readU8(); + const ephemeralPubKeyLen = readU8(); + const ephemeralPubKey = read(ephemeralPubKeyLen); + const ivLen = readU8(); + const iv = read(ivLen); + const cipherTextLenBytes = read(4); + const cipherTextLen = new DataView(cipherTextLenBytes.buffer, cipherTextLenBytes.byteOffset, 4).getUint32(0, false); + const cipherText = read(cipherTextLen); + if (o !== payloadBytes.length) throw new Error('BAD_ENCRYPTED_BODY'); + return { + cryptoMethod, + cryptoVersion, + ephemeralPubKey, + iv, + cipherText, + }; +} + +function classifyDmDecryptFailure(error) { + const code = String(error?.message || '').trim(); + if (code === 'BAD_ENCRYPTED_BODY') return DM_DECRYPT_ERROR_CORRUPTED; + if (code === 'Неподдерживаемый метод шифрования DM') return DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT; + if (code === 'OperationError') return DM_DECRYPT_ERROR_DECRYPT_FAILED; + return DM_DECRYPT_ERROR_DECRYPT_FAILED; +} + +function parseReadReceiptBodyBytes(payloadBytes) { + if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_RECEIPT_PAYLOAD_LEN'); + let o = 0; + const read = (n) => { + if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_PAYLOAD_LEN'); + const out = payloadBytes.slice(o, o + n); + o += n; + return out; + }; + const readU8 = () => read(1)[0]; + const readU32 = () => { + const part = read(4); + return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false); + }; + const readU64 = () => { + const part = read(8); + return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false)); + }; + const readAscii = (code) => { + const len = readU8(); + const part = read(len); + for (let i = 0; i < part.length; i += 1) { + const c = part[i]; + if (c < 0x20 || c > 0x7e) throw new Error(code); + } + return new TextDecoder().decode(part); + }; + + const refToLogin = readAscii('BAD_RECEIPT_TO_LOGIN'); + const refFromLogin = readAscii('BAD_RECEIPT_FROM_LOGIN'); + const refTimeMs = readU64(); + const refNonce = readU32(); + if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_PAYLOAD_LEN'); + return { refToLogin, refFromLogin, refTimeMs, refNonce }; +} + function parseSignedMessageBlockBytes(bytes) { if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); let o = 0; @@ -253,11 +387,6 @@ function parseSignedMessageBlockBytes(bytes) { return out; }; const readU8 = () => read(1)[0]; - const readU16 = () => { - const part = read(2); - const view = new DataView(part.buffer, part.byteOffset, 2); - return view.getUint16(0, false); - }; const readU32 = () => { const part = read(4); const view = new DataView(part.buffer, part.byteOffset, 4); @@ -287,87 +416,75 @@ function parseSignedMessageBlockBytes(bytes) { return true; }; - if (startsWith(DM_PREFIX_V1)) { - read(DM_PREFIX_V1.length); - const formatVersionMajor = readU8(); - const formatVersionMinor = readU8(); - const toLogin = readAscii(); - const fromLogin = readAscii(); - const timeMs = readU64(); - const nonce = readU32(); - const messageType = readU16(); - const revisionTimeMs = readU64(); - const attachmentsCount = readU8(); - if (attachmentsCount !== 0) throw new Error('ATTACHMENTS_DISABLED'); - const encryptedBodyLen = readU32(); - const encryptedBodyBytes = read(encryptedBodyLen); - const signatureBytes = read(64); - if (o !== bytes.length) throw new Error('BAD_LEN'); - const signedBody = bytes.slice(0, bytes.length - 64); - const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }); - const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); - const bodyText = new TextDecoder().decode(encryptedBodyBytes); - return { - toLogin, - fromLogin, - timeMs, - nonce, - messageType, - revisionTimeMs, - formatVersionMajor, - formatVersionMinor, - encryptedBodyBytes, - encryptedBodyText: bodyText, - text: bodyText, - bodyAttachments: [], - payloadBytes: encryptedBodyBytes, - signatureBytes, - signedBody, - rawBytes: bytes, - baseKey, - messageKey, - legacyFormat: false, - deleted: attachmentsCount === 0 && encryptedBodyLen === 0, - }; - } + if (!startsWith(DM_PREFIX_V1)) throw new Error('BAD_PREFIX'); - const prefix = read(DM2_PREFIX.length); - for (let i = 0; i < DM2_PREFIX.length; i += 1) { - if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX'); + read(DM_PREFIX_V1.length); + const formatVersionMajor = readU8(); + const formatVersionMinor = readU8(); + if (formatVersionMajor !== DM_FORMAT_VERSION_MAJOR || formatVersionMinor !== DM_FORMAT_VERSION_MINOR) { + throw new Error('BAD_FORMAT_VERSION'); } - const toLogin = readAscii(); const fromLogin = readAscii(); const timeMs = readU64(); const nonce = readU32(); - const messageType = readU16(); - const payloadLen = readU16(); - const payloadBytes = read(payloadLen); + const messageType = readU8(); + if (messageType < DM_TYPE_INCOMING || messageType > DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { + throw new Error('BAD_MESSAGE_TYPE'); + } + const revisionTimeMs = readU64(); + const reencryptedAtMs = readU64(); + const bodyLen = readU32(); + const bodyBytes = read(bodyLen); const signatureBytes = read(64); if (o !== bytes.length) throw new Error('BAD_LEN'); const signedBody = bytes.slice(0, bytes.length - 64); - const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }); - const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); + const baseKey = dmBaseKey({ toLogin, fromLogin, timeMs, nonce }); + const messageKey = dmMessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }); + let encryptedBodyPayload = null; + let readReceiptPayload = null; + if (messageType === DM_TYPE_INCOMING || messageType === DM_TYPE_OUTGOING_COPY) { + if (bodyBytes.length === 0) throw new Error('BAD_MESSAGE_LEN'); + } else if (messageType === DM_TYPE_READ_INCOMING || messageType === DM_TYPE_READ_OUTGOING_COPY) { + if (bodyBytes.length === 0) throw new Error('BAD_RECEIPT_PAYLOAD_LEN'); + if (revisionTimeMs !== 0 || reencryptedAtMs !== 0) throw new Error('BAD_RECEIPT_REVISION'); + readReceiptPayload = parseReadReceiptBodyBytes(bodyBytes); + } else if (messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT) { + if (bodyBytes.length !== 0) throw new Error('BAD_DELETE_BODY'); + if (revisionTimeMs <= 0) throw new Error('BAD_REVISION_TIME'); + if (reencryptedAtMs !== 0) throw new Error('BAD_REENCRYPTED_TIME'); + } else if (messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT) { + if (bodyBytes.length !== 0) throw new Error('BAD_DELETE_BODY'); + if (revisionTimeMs !== 0) throw new Error('BAD_REVISION_TIME'); + if (reencryptedAtMs !== 0) throw new Error('BAD_REENCRYPTED_TIME'); + } + return { toLogin, fromLogin, timeMs, nonce, messageType, - revisionTimeMs: 0, - encryptedBodyBytes: payloadBytes, - encryptedBodyText: new TextDecoder().decode(payloadBytes), - text: new TextDecoder().decode(payloadBytes), + revisionTimeMs, + reencryptedAtMs, + formatVersionMajor, + formatVersionMinor, + encryptedBodyBytes: bodyBytes, + encryptedBodyPayload, + text: '', bodyAttachments: [], - payloadBytes, + payloadBytes: bodyBytes, signatureBytes, signedBody, rawBytes: bytes, baseKey, messageKey, - legacyFormat: true, - deleted: false, + legacyFormat: false, + deleted: messageType >= DM_TYPE_MESSAGE_DELETED_BY_SENDER, + isMessageDelete: messageType === DM_TYPE_MESSAGE_DELETED_BY_SENDER || messageType === DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT, + isConversationDelete: messageType === DM_TYPE_CONVERSATION_DELETED_BY_SENDER || messageType === DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT, + readReceiptPayload, }; } @@ -737,6 +854,16 @@ function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType ); } +function buildRemoteBlockBodyBytes({ msgType, msgSubType, msgVersion = 1, bodyBytes }) { + const body = bodyBytes || new Uint8Array(0); + return concatBytes( + int16Bytes(msgType), + int16Bytes(msgSubType), + int16Bytes(msgVersion), + body, + ); +} + export class AuthService { constructor(serverUrl) { this.serverUrl = normalizeServerUrl(serverUrl); @@ -745,6 +872,9 @@ export class AuthService { this.writeLocks = new Map(); this.passwordKeyBundleCache = new Map(); this.passwordKeyBundleInFlight = new Map(); + this.currentLogin = ''; + this.currentSessionId = ''; + this.remoteAddBlockSessionId = ''; } async reconnect(serverUrl) { @@ -757,6 +887,20 @@ export class AuthService { this.writeLocks.clear(); } + setActiveSessionContext({ login = '', sessionId = '' } = {}) { + this.currentLogin = String(login || '').trim(); + this.currentSessionId = String(sessionId || '').trim(); + } + + clearActiveSessionContext() { + this.currentLogin = ''; + this.currentSessionId = ''; + } + + setRemoteAddBlockSessionId(sessionId = '') { + this.remoteAddBlockSessionId = String(sessionId || '').trim(); + } + runWriteLocked(lockKey, runAction) { const key = String(lockKey || '').trim() || 'write'; if (this.writeLocks.has(key)) return this.writeLocks.get(key); @@ -1096,6 +1240,100 @@ export class AuthService { return response?.payload?.sessions || []; } + async waitForSignal({ signalType, signalRequestId, timeoutMs = 15000 }) { + const cleanSignalType = String(signalType || '').trim(); + const cleanSignalRequestId = String(signalRequestId || '').trim(); + if (!cleanSignalType || !cleanSignalRequestId) { + throw new Error('waitForSignal: не переданы signalType/signalRequestId'); + } + + return new Promise((resolve, reject) => { + let unsubscribe = () => {}; + const timer = window.setTimeout(() => { + unsubscribe(); + reject(new Error(`Таймаут ожидания сигнала ${cleanSignalType}`)); + }, timeoutMs); + + unsubscribe = this.onEvent('IncomingSignal', (evt) => { + const payload = evt?.payload || {}; + if (String(payload?.signalType || '').trim() !== cleanSignalType) return; + if (String(payload?.signalRequestId || '').trim() !== cleanSignalRequestId) return; + window.clearTimeout(timer); + unsubscribe(); + resolve(payload); + }); + }); + } + + async sendSignal({ + toLogin, + targetMode = SIGNAL_TARGET_SINGLE, + targetSessionId = '', + signalType, + signalRequestId, + data = '', + storagePwd = '', + includeClientSignature = true, + timeMs = Date.now(), + } = {}) { + const cleanToLogin = String(toLogin || '').trim(); + const cleanTargetMode = String(targetMode || '').trim(); + const cleanTargetSessionId = String(targetSessionId || '').trim(); + const cleanSignalType = String(signalType || '').trim(); + const cleanSignalRequestId = String(signalRequestId || '').trim(); + const cleanLogin = String(this.currentLogin || '').trim(); + const cleanSessionId = String(this.currentSessionId || '').trim(); + if (!cleanLogin || !cleanSessionId) throw new Error('SendSignal: нет активного login/sessionId'); + if (!cleanToLogin || !cleanSignalType || !cleanSignalRequestId) { + throw new Error('SendSignal: не переданы toLogin/signalType/signalRequestId'); + } + if (cleanTargetMode !== SIGNAL_TARGET_SINGLE && cleanTargetMode !== SIGNAL_TARGET_ALL) { + throw new Error('SendSignal: bad targetMode'); + } + if (cleanTargetMode === SIGNAL_TARGET_SINGLE && !cleanTargetSessionId) { + throw new Error('SendSignal: targetSessionId обязателен для single_session'); + } + + const sessionMaterial = await loadSessionMaterial(cleanLogin); + if (!sessionMaterial?.sessionPrivPkcs8) { + throw new Error('На устройстве нет сохранённого session key для SendSignal'); + } + const sessionPrivateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); + + const dataText = typeof data === 'string' ? data : JSON.stringify(data || {}); + const dataSha256B64 = await sha256Base64FromText(dataText); + + const sessionPreimage = `SEND_SIGNAL_SESSION:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`; + const sessionSignatureB64 = await signBase64(sessionPrivateKey, sessionPreimage); + + let clientSignatureB64 = ''; + if (includeClientSignature) { + if (!storagePwd) throw new Error('SendSignal: нужен storagePwd для подписи client key'); + const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const clientPrivatePkcs8 = String(secrets?.clientKey || '').trim(); + if (!clientPrivatePkcs8) { + throw new Error('На устройстве нет сохранённого client key для SendSignal'); + } + const clientPrivateKey = await importPkcs8Ed25519(clientPrivatePkcs8); + const clientPreimage = `SEND_SIGNAL_CLIENT:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`; + clientSignatureB64 = await signBase64(clientPrivateKey, clientPreimage); + } + + const response = await this.ws.request('SendSignal', { + toLogin: cleanToLogin, + targetMode: cleanTargetMode, + targetSessionId: cleanTargetMode === SIGNAL_TARGET_SINGLE ? cleanTargetSessionId : '', + signalType: cleanSignalType, + signalRequestId: cleanSignalRequestId, + data: dataText, + timeMs: Number(timeMs), + sessionSignatureB64, + clientSignatureB64, + }); + if (response.status !== 200) throw opError('SendSignal', response); + return response.payload || {}; + } + async closeSession(sessionId) { const response = await this.ws.request('CloseActiveSession', { sessionId }); if (response.status !== 200) throw opError('CloseActiveSession', response); @@ -1219,56 +1457,174 @@ export class AuthService { return response.payload || {}; } - async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) { - const cleanLogin = (login || '').trim(); - if (!cleanLogin) throw new Error('Missing login for AddBlock'); - if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing'); - - const resolveFreshCursor = async () => { - const user = await this.getUser(cleanLogin); - const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); - const freshNum = Number(user?.serverLastGlobalNumber); - const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64); - return { - blockchainName, - cursor: { - serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1, - serverLastGlobalHash: freshHash, - }, - }; + async resolveFreshBlockchainCursor(login) { + const cleanLogin = String(login || '').trim(); + const user = await this.getUser(cleanLogin); + const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); + const freshNum = Number(user?.serverLastGlobalNumber); + const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64); + return { + blockchainName, + cursor: { + serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1, + serverLastGlobalHash: freshHash, + }, }; - const freshState = await resolveFreshCursor(); - const blockchainName = freshState.blockchainName; + } - const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd); - const blockchainPrivatePkcs8 = savedKeys?.blockchainKey; - if (!blockchainPrivatePkcs8) { - throw new Error('Missing saved blockchain private key on device'); + async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage }) { + const cleanLogin = String(login || '').trim(); + const cleanBlockchainName = String(blockchainName || '').trim(); + const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX); + if (!cleanLogin || !cleanBlockchainName) throw new Error('submitPreparedAddBlock: missing login/blockchainName'); + if (!(preimage instanceof Uint8Array) || preimage.length === 0) { + throw new Error('submitPreparedAddBlock: bad preimage'); } - const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8); + const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim(); + if (blockchainPrivatePkcs8) { + const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8); + const hash32 = await sha256Bytes(preimage); + const signatureBytes = await signBytes(privateKey, hash32); + const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes); + return this.ws.request('AddBlock', { + blockchainName: cleanBlockchainName, + blockNumber: Number(blockNumber), + prevBlockHash: cleanPrevBlockHash, + blockBytesB64: bytesToBase64(fullBlock), + }); + } + + const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim(); + if (!remoteSessionId) { + throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock'); + } + + const signalRequestId = createSignalRequestId('remote-addblock'); + const responseWait = this.waitForSignal({ + signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT, + signalRequestId, + timeoutMs: 20000, + }); + + const signalData = { + operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST, + signalRequestId, + login: cleanLogin, + blockchainName: cleanBlockchainName, + blockNumber: Number(blockNumber), + prevBlockHash: cleanPrevBlockHash, + blockPreimageB64: bytesToBase64(preimage), + }; + + await this.sendSignal({ + toLogin: cleanLogin, + targetMode: SIGNAL_TARGET_SINGLE, + targetSessionId: remoteSessionId, + signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST, + signalRequestId, + data: JSON.stringify(signalData), + storagePwd, + includeClientSignature: true, + }); + + const signalPayload = await responseWait; + let result = {}; + try { + result = JSON.parse(String(signalPayload?.data || '{}')); + } catch { + throw new Error('Некорректный ответ remote AddBlock от homeserver'); + } + if (!result?.ok) { + throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed')); + } + + return { + status: 200, + payload: { + serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? blockNumber), + serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX), + remote: true, + }, + }; + } + + async submitRemoteAddBlockBody({ login, storagePwd, blockchainName, blockBodyBytes }) { + const cleanLogin = String(login || '').trim(); + const cleanBlockchainName = String(blockchainName || '').trim(); + if (!cleanLogin || !cleanBlockchainName) throw new Error('submitRemoteAddBlockBody: missing login/blockchainName'); + if (!(blockBodyBytes instanceof Uint8Array) || blockBodyBytes.length < 6) { + throw new Error('submitRemoteAddBlockBody: bad blockBodyBytes'); + } + + const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim(); + if (!remoteSessionId) { + throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock'); + } + + const signalRequestId = createSignalRequestId('remote-addblock'); + const responseWait = this.waitForSignal({ + signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT, + signalRequestId, + timeoutMs: 20000, + }); + + const signalData = { + operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST, + signalRequestId, + blockchainName: cleanBlockchainName, + blockBodyB64: bytesToBase64(blockBodyBytes), + }; + + await this.sendSignal({ + toLogin: cleanLogin, + targetMode: SIGNAL_TARGET_SINGLE, + targetSessionId: remoteSessionId, + signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST, + signalRequestId, + data: JSON.stringify(signalData), + storagePwd, + includeClientSignature: true, + }); + + const signalPayload = await responseWait; + let result = {}; + try { + result = JSON.parse(String(signalPayload?.data || '{}')); + } catch { + throw new Error('Некорректный ответ remote AddBlock от homeserver'); + } + if (!result?.ok) { + throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed')); + } + + return { + status: 200, + payload: { + serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? -1), + serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX), + remote: true, + }, + }; + } + + async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage }) { + let freshState = await resolveFreshState(); + let blockchainName = String(freshState?.blockchainName || '').trim(); + if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty'); const tryAdd = async (cursor) => { const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1; const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64); - const preimage = buildBlockPreimage({ - prevBlockHashHex: prevBlockHash, - blockNumber, - msgType, - msgSubType, - msgVersion, - bodyBytes, - }); - - const hash32 = await sha256Bytes(preimage); - const signatureBytes = await signBytes(privateKey, hash32); - const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes); - - return this.ws.request('AddBlock', { + const preimage = await buildPreimage({ blockNumber, prevBlockHash, blockchainName }); + return this.submitPreparedAddBlock({ + login, + storagePwd, blockchainName, blockNumber, prevBlockHash, - blockBytesB64: bytesToBase64(fullBlock), + preimage, }); }; @@ -1281,13 +1637,53 @@ export class AuthService { cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() }; response = await tryAdd(cursor); } else { - const refreshed = await resolveFreshCursor(); - cursor = refreshed.cursor; + freshState = await resolveFreshState(); + blockchainName = String(freshState?.blockchainName || blockchainName).trim() || blockchainName; + cursor = freshState.cursor; response = await tryAdd(cursor); } } if (response.status !== 200) throw opError('AddBlock', response); + return { + response, + blockchainName, + }; + } + + async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) { + const cleanLogin = (login || '').trim(); + if (!cleanLogin) throw new Error('Missing login for AddBlock'); + if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing'); + + const keyBundle = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const blockchainPrivatePkcs8 = String(keyBundle?.blockchainKey || '').trim(); + if (!blockchainPrivatePkcs8) { + const user = await this.getUser(cleanLogin); + const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); + if (!blockchainName) throw new Error('Не удалось определить blockchainName для remote AddBlock'); + const response = await this.submitRemoteAddBlockBody({ + login: cleanLogin, + storagePwd, + blockchainName, + blockBodyBytes: buildRemoteBlockBodyBytes({ msgType, msgSubType, msgVersion, bodyBytes }), + }); + return response.payload || {}; + } + + const { response, blockchainName } = await this.runAddBlockWithRetry({ + login: cleanLogin, + storagePwd, + resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin), + buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({ + prevBlockHashHex: prevBlockHash, + blockNumber, + msgType, + msgSubType, + msgVersion, + bodyBytes, + }), + }); const payload = response.payload || {}; const acceptedNum = Number(payload?.serverLastGlobalNumber); @@ -1893,64 +2289,28 @@ export class AuthService { return response.payload || {}; } - async buildSignedDm2Block({ - login, - toLogin, - storagePwd, - timeMs, - nonce, - messageType, - payloadBytes, - }) { - const cleanFromLogin = String(login || '').trim(); - const cleanToLogin = String(toLogin || '').trim(); - if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); - if (!storagePwd) throw new Error('Не передан storagePwd для подписи'); - - const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); - const clientPriv = secrets?.clientKey || secrets?.clientKey; - if (!clientPriv) throw new Error('Не найден приватный clientKey'); - const privateKey = await importPkcs8Ed25519(clientPriv); - - const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin'); - const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin'); - if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) { - throw new Error('payload должен быть 1..4096 байт'); - } - - const preimage = concatBytes( - DM2_PREFIX, - uint8Bytes(toBytes.length), toBytes, - uint8Bytes(fromBytes.length), fromBytes, - uint64Bytes(timeMs), - uint32Bytes(nonce), - uint16Bytes(messageType), - uint16Bytes(payloadBytes.length), - payloadBytes, - ); - const signature = await signBytes(privateKey, preimage); - return concatBytes(preimage, signature); - } - - async buildSignedDmV1Block({ - login, + async buildSignedDmBlock({ + signerLogin, + fromLogin, toLogin, storagePwd, timeMs, nonce, messageType, revisionTimeMs = 0, - encryptedBodyBytes = new Uint8Array(0), + reencryptedAtMs = 0, + bodyBytes = new Uint8Array(0), }) { - const cleanFromLogin = String(login || '').trim(); + const cleanSignerLogin = String(signerLogin || '').trim(); + const cleanFromLogin = String(fromLogin || '').trim(); const cleanToLogin = String(toLogin || '').trim(); - if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin'); + if (!cleanSignerLogin || !cleanFromLogin || !cleanToLogin) throw new Error('Не передан signerLogin/fromLogin/toLogin'); if (!storagePwd) throw new Error('Не передан storagePwd для подписи'); - if (!(encryptedBodyBytes instanceof Uint8Array) || encryptedBodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) { - throw new Error(`encryptedBody должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`); + if (!(bodyBytes instanceof Uint8Array) || bodyBytes.length > DM_MAX_ENCRYPTED_BODY_BYTES) { + throw new Error(`body должен быть 0..${DM_MAX_ENCRYPTED_BODY_BYTES} байт`); } - const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); + const secrets = await loadEncryptedUserSecrets(cleanSignerLogin, storagePwd); const clientPriv = secrets?.clientKey || secrets?.clientKey; if (!clientPriv) throw new Error('Не найден приватный clientKey'); const privateKey = await importPkcs8Ed25519(clientPriv); @@ -1965,11 +2325,11 @@ export class AuthService { uint8Bytes(fromBytes.length), fromBytes, uint64Bytes(timeMs), uint32Bytes(nonce), - uint16Bytes(messageType), + uint8Bytes(messageType), uint64Bytes(revisionTimeMs), - uint8Bytes(0), - uint32Bytes(encryptedBodyBytes.length), - encryptedBodyBytes, + uint64Bytes(reencryptedAtMs), + uint32Bytes(bodyBytes.length), + bodyBytes, ); const signature = await signBytes(privateKey, preimage); return concatBytes(preimage, signature); @@ -1981,39 +2341,43 @@ export class AuthService { } parseReadReceiptPayload(payloadBytes) { - if (!(payloadBytes instanceof Uint8Array)) throw new Error('Expected Uint8Array'); - let o = 0; - const read = (n) => { - if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_LEN'); - const out = payloadBytes.slice(o, o + n); - o += n; - return out; + return parseReadReceiptBodyBytes(payloadBytes); + } + + classifyDmDecryptFailure(error) { + return classifyDmDecryptFailure(error); + } + + async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) { + const parsedBlock = parsed || this.parseSignedMessageBlob(blobB64); + const messageType = Number(parsedBlock?.messageType || 0); + if (messageType !== DM_TYPE_INCOMING && messageType !== DM_TYPE_OUTGOING_COPY) { + return { text: '', plainBytes: new Uint8Array(0) }; + } + if (!storagePwd) throw new Error('Не передан storagePwd для расшифровки'); + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) throw new Error('Не передан login для расшифровки'); + + const payload = parsedBlock.encryptedBodyPayload || parseEncryptedDmBodyBytes(parsedBlock.payloadBytes); + if (payload.cryptoMethod !== DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM || payload.cryptoVersion !== DM_CRYPTO_VERSION_1_0) { + throw new Error('Неподдерживаемый метод шифрования DM'); + } + + const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd); + const clientPrivPkcs8 = String(secrets?.clientKey || '').trim(); + if (!clientPrivPkcs8) throw new Error('Не найден приватный clientKey'); + const recipientSeed = extractEd25519SeedFromPkcs8B64(clientPrivPkcs8); + const recipientX25519Priv = ed25519SeedToX25519Private(recipientSeed); + const recipientEd25519PubB64 = await publicKeyB64FromPkcs8Ed25519(clientPrivPkcs8); + const recipientX25519Pub = ed25519PublicToX25519Public(base64ToBytes(recipientEd25519PubB64)); + const sharedSecret = x25519SharedSecret(recipientX25519Priv, payload.ephemeralPubKey); + const salt = concatBytes(payload.ephemeralPubKey, recipientX25519Pub); + const aesKeyBytes = await hkdfSha256(sharedSecret, salt, DM_HKDF_INFO, 32); + const plainBytes = await decryptBytesAesGcm(payload.cipherText, aesKeyBytes, payload.iv); + return { + text: new TextDecoder().decode(plainBytes), + plainBytes, }; - const readU8 = () => read(1)[0]; - const readU16 = () => { - const part = read(2); - return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false); - }; - const readU32 = () => { - const part = read(4); - return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false); - }; - const readU64 = () => { - const part = read(8); - return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false)); - }; - const readAscii = () => { - const len = readU8(); - const part = read(len); - return new TextDecoder().decode(part); - }; - const refToLogin = readAscii(); - const refFromLogin = readAscii(); - const refTimeMs = readU64(); - const refNonce = readU32(); - const refType = readU16(); - if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN'); - return { refToLogin, refFromLogin, refTimeMs, refNonce, refType }; } async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) { @@ -2037,6 +2401,7 @@ export class AuthService { timeMs, nonce, revisionTimeMs = 0, + reencryptedAtMs = 0, }) { const cleanFromLogin = String(login || '').trim(); const cleanToLogin = String(toLogin || '').trim(); @@ -2045,30 +2410,54 @@ export class AuthService { const normalizedTimeMs = Number(timeMs); const normalizedNonce = Number(nonce); const normalizedRevisionTimeMs = Number(revisionTimeMs || 0); + const normalizedReencryptedAtMs = Number(reencryptedAtMs || 0); if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs'); if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce'); if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs < 0) throw new Error('Некорректный revisionTimeMs'); - const encryptedBodyBytes = utf8Bytes(cleanText); + if (!Number.isFinite(normalizedReencryptedAtMs) || normalizedReencryptedAtMs < 0) throw new Error('Некорректный reencryptedAtMs'); - const incomingBlock = await this.buildSignedDmV1Block({ - login: cleanFromLogin, - toLogin: cleanToLogin, - storagePwd, - timeMs: normalizedTimeMs, - nonce: normalizedNonce, - messageType: DM2_TYPE_INCOMING, - revisionTimeMs: normalizedRevisionTimeMs, - encryptedBodyBytes, + const plainBytes = utf8Bytes(cleanText); + const targetUser = await this.getUser(cleanToLogin); + if (!targetUser?.exists || !String(targetUser?.clientKey || '').trim()) { + throw new Error('Не найден clientKey получателя'); + } + const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd); + const senderPrivPkcs8 = String(secrets?.clientKey || '').trim(); + if (!senderPrivPkcs8) throw new Error('Не найден приватный clientKey отправителя'); + const senderPublicKeyB64 = await publicKeyB64FromPkcs8Ed25519(senderPrivPkcs8); + + const incomingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({ + plainBytes, + recipientClientKeyB64: String(targetUser.clientKey || '').trim(), }); - const outgoingBlock = await this.buildSignedDmV1Block({ - login: cleanFromLogin, + const outgoingEncryptedBodyBytes = await buildDmEncryptedBodyBytes({ + plainBytes, + recipientClientKeyB64: senderPublicKeyB64, + }); + + const incomingBlock = await this.buildSignedDmBlock({ + signerLogin: cleanFromLogin, + fromLogin: cleanFromLogin, toLogin: cleanToLogin, storagePwd, timeMs: normalizedTimeMs, nonce: normalizedNonce, - messageType: DM2_TYPE_OUTGOING_COPY, + messageType: DM_TYPE_INCOMING, revisionTimeMs: normalizedRevisionTimeMs, - encryptedBodyBytes, + reencryptedAtMs: normalizedReencryptedAtMs, + bodyBytes: incomingEncryptedBodyBytes, + }); + const outgoingBlock = await this.buildSignedDmBlock({ + signerLogin: cleanFromLogin, + fromLogin: cleanFromLogin, + toLogin: cleanToLogin, + storagePwd, + timeMs: normalizedTimeMs, + nonce: normalizedNonce, + messageType: DM_TYPE_OUTGOING_COPY, + revisionTimeMs: normalizedRevisionTimeMs, + reencryptedAtMs: normalizedReencryptedAtMs, + bodyBytes: outgoingEncryptedBodyBytes, }); const payload = await this.sendMessagePair({ @@ -2079,7 +2468,7 @@ export class AuthService { ...payload, localIncomingBlobB64: bytesToBase64(incomingBlock), localOutgoingBlobB64: bytesToBase64(outgoingBlock), - localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }), + localBaseKey: dmBaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs: normalizedTimeMs, nonce: normalizedNonce }), }; } @@ -2097,40 +2486,62 @@ export class AuthService { }); } - async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs }) { - return this.sendDirectMessageRevision({ - login, - toLogin, - text: '', + async deleteDirectMessage({ login, toLogin, storagePwd, timeMs, nonce, revisionTimeMs, deleteByRecipient = false }) { + const cleanLogin = String(login || '').trim(); + const cleanPeerLogin = String(toLogin || '').trim(); + if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin'); + const normalizedTimeMs = Number(timeMs); + const normalizedNonce = Number(nonce); + const normalizedRevisionTimeMs = Number(revisionTimeMs || 0); + if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs'); + if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce'); + if (!Number.isFinite(normalizedRevisionTimeMs) || normalizedRevisionTimeMs <= 0) throw new Error('Некорректный revisionTimeMs'); + + const block = await this.buildSignedDmBlock({ + signerLogin: cleanLogin, + fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin, + toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin, storagePwd, - timeMs, - nonce, - revisionTimeMs, + timeMs: normalizedTimeMs, + nonce: normalizedNonce, + messageType: deleteByRecipient ? DM_TYPE_MESSAGE_DELETED_BY_RECIPIENT : DM_TYPE_MESSAGE_DELETED_BY_SENDER, + revisionTimeMs: normalizedRevisionTimeMs, + reencryptedAtMs: 0, + bodyBytes: new Uint8Array(0), }); + const blobB64 = bytesToBase64(block); + const response = await this.ws.request('DeleteMessage', { blobB64 }); + if (response.status !== 200) throw opError('DeleteMessage', response); + return { + ...(response.payload || {}), + localBlobB64: blobB64, + }; } - async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) { + async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce }) { const timeMs = Date.now(); const nonce = Math.floor(Math.random() * 0x100000000); - const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }); + const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce }); - const type3 = await this.buildSignedDm2Block({ - login, + const type3 = await this.buildSignedDmBlock({ + signerLogin: login, + fromLogin: login, toLogin, storagePwd, timeMs, nonce, - messageType: DM2_TYPE_READ_INCOMING, - payloadBytes: payload, + messageType: DM_TYPE_READ_INCOMING, + bodyBytes: payload, }); - const type4 = await this.buildSignedDm2Block({ - login, + const type4 = await this.buildSignedDmBlock({ + signerLogin: login, + fromLogin: login, toLogin, storagePwd, timeMs, nonce, - messageType: DM2_TYPE_READ_OUTGOING_COPY, - payloadBytes: payload, + messageType: DM_TYPE_READ_OUTGOING_COPY, + bodyBytes: payload, }); return this.sendMessagePair({ incomingBlobB64: bytesToBase64(type3), @@ -2138,6 +2549,36 @@ export class AuthService { }); } + async deleteConversation({ login, toLogin, storagePwd, deleteByRecipient = false, timeMs = Date.now(), nonce = Math.floor(Math.random() * 0x100000000) }) { + const cleanLogin = String(login || '').trim(); + const cleanPeerLogin = String(toLogin || '').trim(); + if (!cleanLogin || !cleanPeerLogin) throw new Error('Не передан login/toLogin'); + const normalizedTimeMs = Number(timeMs); + const normalizedNonce = Number(nonce); + if (!Number.isFinite(normalizedTimeMs) || normalizedTimeMs <= 0) throw new Error('Некорректный timeMs'); + if (!Number.isFinite(normalizedNonce) || normalizedNonce < 0) throw new Error('Некорректный nonce'); + + const block = await this.buildSignedDmBlock({ + signerLogin: cleanLogin, + fromLogin: deleteByRecipient ? cleanPeerLogin : cleanLogin, + toLogin: deleteByRecipient ? cleanLogin : cleanPeerLogin, + storagePwd, + timeMs: normalizedTimeMs, + nonce: normalizedNonce, + messageType: deleteByRecipient ? DM_TYPE_CONVERSATION_DELETED_BY_RECIPIENT : DM_TYPE_CONVERSATION_DELETED_BY_SENDER, + revisionTimeMs: 0, + reencryptedAtMs: 0, + bodyBytes: new Uint8Array(0), + }); + const blobB64 = bytesToBase64(block); + const response = await this.ws.request('DeleteConversation', { blobB64 }); + if (response.status !== 200) throw opError('DeleteConversation', response); + return { + ...(response.payload || {}), + localBlobB64: blobB64, + }; + } + async ackSessionDelivery(messageKey) { const response = await this.ws.request('AckSessionDelivery', { messageKey }); if (response.status !== 200) throw opError('AckSessionDelivery', response); @@ -2249,79 +2690,22 @@ export class AuthService { if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.'); if (!cleanValue) throw new Error('Значение параметра не может быть пустым.'); if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.'); - - const user = await this.getUser(cleanLogin); - const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); - const freshNum = Number(user?.serverLastGlobalNumber); - const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase(); - const freshCursor = { - serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1, - serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX, - }; - - const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd); - const blockchainPrivatePkcs8 = savedKeys?.blockchainKey; - if (!blockchainPrivatePkcs8) { - throw new Error('На устройстве нет сохраненного приватного blockchainKey'); - } - - const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8); - - const tryAdd = async (cursor) => { - const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1; - const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX); - - // Для USER_PARAM отправляем старт новой line-цепочки: - // prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1. - // Этот формат соответствует BodyHasLine правилам на сервере. - const bodyBytes = makeUserParamBodyBytes({ - lineCode: 0, - prevLineNumber: -1, - prevLineHashHex: ZERO_HASH_HEX, - thisLineNumber: -1, - key: cleanParam, - value: cleanValue, - }); - - const preimage = concatBytes( - int16Bytes(0), - hexToBytes(prevBlockHash), - int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length), - int32Bytes(blockNumber), - int64Bytes(Math.floor(Date.now() / 1000)), - int16Bytes(4), - int16Bytes(1), - int16Bytes(1), - bodyBytes, - ); - - const hash32 = await sha256Bytes(preimage); - const signatureBytes = await signBytes(privateKey, hash32); - const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes); - - const response = await this.ws.request('AddBlock', { - blockchainName, - blockNumber, - prevBlockHash, - blockBytesB64: bytesToBase64(fullBlock), - }); - - return response; - }; - - let cursor = freshCursor; - let response = await tryAdd(cursor); - if (response.status !== 200) { - const knownNum = Number(response?.payload?.serverLastGlobalNumber); - const knownHash = String(response?.payload?.serverLastGlobalHash || ''); - if (Number.isFinite(knownNum) && knownHash.length === 64) { - cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash }; - response = await tryAdd(cursor); - } - } - - if (response.status !== 200) throw opError('AddBlock', response); - return response.payload || {}; + const bodyBytes = makeUserParamBodyBytes({ + lineCode: 0, + prevLineNumber: -1, + prevLineHashHex: ZERO_HASH_HEX, + thisLineNumber: -1, + key: cleanParam, + value: cleanValue, + }); + return this.addBlockSigned({ + login: cleanLogin, + storagePwd, + msgType: 4, + msgSubType: 1, + msgVersion: 1, + bodyBytes, + }); } async addBlockConnection({ login, toLogin, subType, storagePwd }) { @@ -2337,79 +2721,27 @@ export class AuthService { const user = await this.getUser(cleanLogin); if (user?.exists === false) throw new Error('Текущий пользователь не найден.'); - const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); - const freshNum = Number(user?.serverLastGlobalNumber); - const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase(); - const freshCursor = { - serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1, - serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX, - }; const targetUser = await this.getUser(cleanToLogin); if (!targetUser?.exists) throw new Error('Пользователь цели не найден.'); const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim(); - - const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd); - const blockchainPrivatePkcs8 = savedKeys?.blockchainKey; - if (!blockchainPrivatePkcs8) { - throw new Error('На устройстве нет сохраненного приватного blockchainKey'); - } - const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8); - - const tryAdd = async (cursor) => { - const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1; - const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX); - - // Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку: - // prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1. - // target для user-связей указывает на HEADER пользователя (blockNumber=0). - const bodyBytes = makeConnectionBodyBytes({ - lineCode: 0, - prevLineNumber: -1, - prevLineHashHex: ZERO_HASH_HEX, - thisLineNumber: -1, - toBlockchainName, - toBlockNumber: 0, - toBlockHashHex: ZERO_HASH_HEX, - }); - - const preimage = concatBytes( - int16Bytes(0), - hexToBytes(prevBlockHash), - int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length), - int32Bytes(blockNumber), - int64Bytes(Math.floor(Date.now() / 1000)), - int16Bytes(3), - int16Bytes(cleanSubType), - int16Bytes(1), - bodyBytes, - ); - - const hash32 = await sha256Bytes(preimage); - const signatureBytes = await signBytes(privateKey, hash32); - const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes); - - return this.ws.request('AddBlock', { - blockchainName, - blockNumber, - prevBlockHash, - blockBytesB64: bytesToBase64(fullBlock), - }); - }; - - let cursor = freshCursor; - let response = await tryAdd(cursor); - if (response.status !== 200) { - const knownNum = Number(response?.payload?.serverLastGlobalNumber); - const knownHash = String(response?.payload?.serverLastGlobalHash || '').trim().toLowerCase(); - if (Number.isFinite(knownNum) && knownHash.length === 64) { - cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash }; - response = await tryAdd(cursor); - } - } - - if (response.status !== 200) throw opError('AddBlock', response); - return response.payload || {}; + const bodyBytes = makeConnectionBodyBytes({ + lineCode: 0, + prevLineNumber: -1, + prevLineHashHex: ZERO_HASH_HEX, + thisLineNumber: -1, + toBlockchainName, + toBlockNumber: 0, + toBlockHashHex: ZERO_HASH_HEX, + }); + return this.addBlockSigned({ + login: cleanLogin, + storagePwd, + msgType: 3, + msgSubType: cleanSubType, + msgVersion: 1, + bodyBytes, + }); } diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index e14c3ff..2bd8e71 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -1,4 +1,5 @@ -import { addSystemChatMessage, authService, authorizeSession, state } from '../state.js'; +import { addAppLogEntry, addSignedMessageToChat, authService, authorizeSession, state } from '../state.js'; +import { buildDmCallTechBlock } from './dm-tech-blocks.js'; const TYPES = { INVITE: 100, @@ -26,11 +27,98 @@ let toneFlip = false; const DEFAULT_ICE_SERVERS = Object.freeze([ { urls: 'stun:stun.l.google.com:19302' }, ]); +const CALL_SUMMARY_MIN_TOTAL_MS = 5000; +const OUTGOING_NO_ACK_TIMEOUT_MS = 10_000; +const OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS = 25_000; +const INCOMING_CONNECT_TIMEOUT_MS = 20_000; +const MAX_TIMELINE_ENTRIES = 64; function nowMs() { return Date.now(); } +function sanitizeTimelineText(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .replace(/[,]/g, ';') + .trim(); +} + +function getSignalTypeName(type) { + const normalized = Number(type); + if (normalized === TYPES.INVITE) return 'invite'; + if (normalized === TYPES.RINGING) return 'ringing'; + if (normalized === TYPES.ACCEPT) return 'accept'; + if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy'; + if (normalized === TYPES.TIMEOUT) return 'timeout'; + if (normalized === TYPES.HANGUP) return 'hangup'; + if (normalized === TYPES.OFFER) return 'offer'; + if (normalized === TYPES.ANSWER) return 'answer'; + if (normalized === TYPES.ICE) return 'ice'; + return `type_${String(type || '')}`; +} + +function ensureCallTimeline(call) { + if (!call) return; + if (!Array.isArray(call.timelineEvents)) call.timelineEvents = []; + if (!Number.isFinite(call.timelineSeq)) call.timelineSeq = 0; + if (!Number.isFinite(call.timelineBaseMs) || call.timelineBaseMs <= 0) { + call.timelineBaseMs = Number(call.startedAtMs || nowMs()); + } +} + +function recordCallTimeline(call, stage, details = '', level = 'info') { + if (!call) return; + ensureCallTimeline(call); + const ts = nowMs(); + const deltaMs = Math.max(0, ts - Number(call.timelineBaseMs || ts)); + const cleanStage = sanitizeTimelineText(stage) || 'unknown'; + const cleanDetails = sanitizeTimelineText(details); + call.timelineSeq += 1; + call.timelineEvents.push({ + seq: call.timelineSeq, + ts, + deltaMs, + stage: cleanStage, + details: cleanDetails, + }); + if (call.timelineEvents.length > MAX_TIMELINE_ENTRIES) { + call.timelineEvents.splice(0, call.timelineEvents.length - MAX_TIMELINE_ENTRIES); + } + addAppLogEntry({ + level, + source: 'call', + message: `[${call.callId || '?'}] +${deltaMs}ms ${cleanStage}`, + details: cleanDetails || buildCallFactsJson(call), + }); +} + +function buildTimelineSummary(call) { + const events = Array.isArray(call?.timelineEvents) ? call.timelineEvents : []; + if (!events.length) return ''; + return events + .map((entry) => { + const head = `+${Number(entry?.deltaMs || 0)}:${sanitizeTimelineText(entry?.stage || '')}`; + const tail = sanitizeTimelineText(entry?.details || ''); + return tail ? `${head}(${tail})` : head; + }) + .join('|') + .slice(0, 1800); +} + +function setRemoteSessionId(call, sessionId, reason = '') { + if (!call) return; + const next = String(sessionId || '').trim(); + if (!next) return; + const prev = String(call.remoteSessionId || '').trim(); + if (prev === next) { + recordCallTimeline(call, 'remote_session_confirmed', `session=${next}; reason=${reason || 'same'}`); + return; + } + call.remoteSessionId = next; + recordCallTimeline(call, 'remote_session_selected', `from=${prev || '-'}; to=${next}; reason=${reason || 'unknown'}`); +} + function resolveCallPreflightTimeoutMs() { const configured = Number(state?.entrySettings?.callPreflightTimeoutMs || 6000); return Math.max(1000, Math.min(20000, Number.isFinite(configured) ? configured : 6000)); @@ -100,6 +188,102 @@ function formatDuration(ms) { return `${sec}с`; } +function isInviteUndelivered(call) { + const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0); + const pushDelivered = Number( + call?.inviteDelivery?.deliveredWebPushSessions + || call?.inviteDelivery?.deliveredFcmSessions + || 0 + ); + return (wsDelivered + pushDelivered) <= 0; +} + +function getInviteDeliveredCount(call) { + const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0); + const pushDelivered = Number( + call?.inviteDelivery?.deliveredWebPushSessions + || call?.inviteDelivery?.deliveredFcmSessions + || 0 + ); + return wsDelivered + pushDelivered; +} + +function buildOutgoingCallSummaryText(call, summaryCode) { + if (!call || call.direction !== 'out') return ''; + if (summaryCode === 'completed') { + return buildDmCallTechBlock({ + status: 'completed', + durationSec: Math.round(Math.max(0, nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())) / 1000), + }); + } + if (summaryCode === 'busy') { + return buildDmCallTechBlock({ status: 'failed', reason: 'busy' }); + } + if (summaryCode === 'declined') { + return buildDmCallTechBlock({ status: 'failed', reason: 'declined' }); + } + if (summaryCode === 'no_answer') { + if (isInviteUndelivered(call)) { + return buildDmCallTechBlock({ status: 'failed', reason: 'offline' }); + } + return buildDmCallTechBlock({ status: 'failed', reason: 'no_answer' }); + } + if (summaryCode === 'error') { + return buildDmCallTechBlock({ status: 'failed', reason: 'connect_failed' }); + } + return ''; +} + +async function applyLocalOutgoingDmCopy(peerLogin, result) { + const localOutgoingBlobB64 = String(result?.localOutgoingBlobB64 || '').trim(); + if (!peerLogin || !localOutgoingBlobB64) return false; + try { + const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64); + const decrypted = await authService.decryptSignedMessageContent({ + parsed, + login: state.session.login, + storagePwd: state.session.storagePwdInMemory, + }); + addSignedMessageToChat({ + chatId: peerLogin, + messageKey: String(result?.outgoingKey || parsed?.messageKey || ''), + baseKey: String(result?.baseKey || result?.localBaseKey || parsed?.baseKey || ''), + from: 'out', + text: String(decrypted?.text || ''), + messageType: Number(parsed?.messageType || 2), + unread: false, + rawBlobB64: localOutgoingBlobB64, + revisionTimeMs: Number(parsed?.revisionTimeMs || 0), + deleted: Boolean(parsed?.deleted), + }); + return true; + } catch { + return false; + } +} + +async function sendOutgoingCallSummaryDm(call, summaryCode) { + if (!call || call.direction !== 'out' || !call.peerLogin) return; + const totalMs = Math.max(0, nowMs() - Number(call.startedAtMs || nowMs())); + if (totalMs < CALL_SUMMARY_MIN_TOTAL_MS) return; + const text = buildOutgoingCallSummaryText(call, summaryCode); + if (!text) return; + const login = String(state?.session?.login || '').trim(); + const storagePwd = String(state?.session?.storagePwdInMemory || '').trim(); + if (!login || !storagePwd) return; + try { + const result = await authService.sendDirectMessage({ + login, + toLogin: call.peerLogin, + text, + storagePwd, + }); + await applyLocalOutgoingDmCopy(call.peerLogin, result); + } catch (error) { + await emitDebug(call, 'warn', 'call_summary_dm_failed', toErrorText(error)); + } +} + function cloneDefaultIceServers() { return DEFAULT_ICE_SERVERS.map((row) => ({ ...row })); } @@ -404,6 +588,7 @@ function setStatus(call, statusText, phase = '') { else if (call.phase === 'incoming') startTone('incoming'); else stopTone(); + recordCallTimeline(call, 'status', `phase=${call.phase || ''}; text=${call.statusText || ''}`); void emitDebug(call, 'info', `call_status: ${call.statusText}`, `callId=${call.callId}`); notifyCallState(); } @@ -443,6 +628,7 @@ function buildCallFactsJson(call, extra = {}) { pcSignalingState: pc?.signalingState || '', hasLocalStream: Boolean(call?.localStream), localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0, + timeline: buildTimelineSummary(call), ...extra, }; try { @@ -472,6 +658,7 @@ function buildCallFactsLine(call, extra = {}) { pcSignalingState: pc?.signalingState || '', hasLocalStream: Boolean(call?.localStream), localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0, + timeline: buildTimelineSummary(call), ...extra, }; return Object.entries(facts) @@ -546,6 +733,7 @@ async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', e reason: String(reason || '').trim(), reportedAtMs: nowMs(), reportedAtIso: toIsoTs(nowMs()), + timelineEntriesCount: Array.isArray(call?.timelineEvents) ? call.timelineEvents.length : 0, ...diagnostics, ...extraFacts, }); @@ -560,10 +748,49 @@ function cleanupTimers(call) { if (call.timers?.ack10s) clearTimeout(call.timers.ack10s); if (call.timers?.total35s) clearTimeout(call.timers.total35s); if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s); + if (call.timers?.incomingConnect20s) clearTimeout(call.timers.incomingConnect20s); if (call.timers?.transportProbe) clearInterval(call.timers.transportProbe); call.timers.transportProbe = null; } +function scheduleOutgoingAckTimeout(call) { + if (!call?.timers) return; + if (call.timers.ack10s) { + clearTimeout(call.timers.ack10s); + call.timers.ack10s = null; + } + const deliveredCount = getInviteDeliveredCount(call); + const timeoutMs = deliveredCount > 0 + ? OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS + : OUTGOING_NO_ACK_TIMEOUT_MS; + const debugReason = deliveredCount > 0 ? 'no_ack_after_delivery_25s' : 'no_ack_10s'; + recordCallTimeline(call, 'ack_timeout_scheduled', `timeoutMs=${timeoutMs}; delivered=${deliveredCount}; reason=${debugReason}`); + call.timers.ack10s = setTimeout(() => { + if (!calls.has(call.callId)) return; + if (call.phase === 'searching' || call.phase === 'ringing') { + recordCallTimeline(call, 'ack_timeout_fired', `phase=${call.phase || ''}; reason=${debugReason}`, 'warn'); + void finalizeCall(call, { localReasonCode: 'no_answer', debugReason }); + } + }, timeoutMs); +} + +function scheduleIncomingConnectTimeout(call) { + if (!call?.timers) return; + if (call.timers.incomingConnect20s) { + clearTimeout(call.timers.incomingConnect20s); + } + recordCallTimeline(call, 'incoming_connect_timeout_scheduled', `timeoutMs=${INCOMING_CONNECT_TIMEOUT_MS}`); + call.timers.incomingConnect20s = setTimeout(() => { + if (!calls.has(call.callId)) return; + if (call.phase !== 'connecting') return; + recordCallTimeline(call, 'incoming_connect_timeout_fired', `phase=${call.phase || ''}`, 'warn'); + void finalizeCall(call, { + localReasonCode: 'no_answer', + debugReason: 'incoming_connect_timeout_20s', + }); + }, INCOMING_CONNECT_TIMEOUT_MS); +} + async function flushPendingIceCandidates(call) { if (!call?.pc) return; const pending = Array.isArray(call.pendingRemoteIceCandidates) ? call.pendingRemoteIceCandidates : []; @@ -743,6 +970,7 @@ function startReconnectFlow(call, reason = 'disconnected') { call.reconnectInProgress = true; call.reconnectAttempts = 0; + recordCallTimeline(call, 'reconnect_start', `reason=${reason}`); setStatus(call, 'Связь прервалась. Переподключаем…', 'reconnecting'); void emitDebug(call, 'warn', 'peer_connection_reconnect_start', `reason=${reason}`); @@ -765,6 +993,7 @@ function startReconnectFlow(call, reason = 'disconnected') { } call.reconnectAttempts += 1; + recordCallTimeline(call, 'reconnect_attempt', `attempt=${call.reconnectAttempts}`); try { const offer = await call.pc.createOffer({ iceRestart: true }); await call.pc.setLocalDescription(offer); @@ -791,49 +1020,6 @@ function startReconnectFlow(call, reason = 'disconnected') { void runAttempt(); } -function pushCallSummary(call, summaryCode) { - if (!call?.peerLogin) return; - const outgoing = call.direction === 'out'; - const from = outgoing ? 'out' : 'in'; - - if (summaryCode === 'busy') { - const text = outgoing - ? '[Звонок] Исходящий: не дозвонились (занято)' - : `[Звонок] Пропущенный входящий от ${call.peerLogin} (занято)`; - addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); - return; - } - if (summaryCode === 'no_answer') { - const text = outgoing - ? '[Звонок] Исходящий: не дозвонились (нет ответа)' - : `[Звонок] Пропущенный входящий от ${call.peerLogin}`; - addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); - return; - } - if (summaryCode === 'declined') { - const text = outgoing - ? '[Звонок] Исходящий: не дозвонились (отклонён)' - : `[Звонок] Входящий звонок от ${call.peerLogin} отклонён`; - addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); - return; - } - if (summaryCode === 'error') { - const text = outgoing - ? '[Звонок] Исходящий: не дозвонились (ошибка соединения)' - : `[Звонок] Входящий звонок от ${call.peerLogin}: ошибка соединения`; - addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); - return; - } - if (summaryCode === 'completed') { - const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())); - const label = outgoing ? 'Исходящий' : 'Входящий'; - addSystemChatMessage(call.peerLogin, `[Звонок] ${label}: разговор ${duration}`, { - from, - kind: 'call-tech', - }); - } -} - async function finalizeCall(call, { localReasonCode = 'error', debugReason = '', @@ -843,10 +1029,19 @@ async function finalizeCall(call, { suppressSummary = false, } = {}) { if (!call) return; + recordCallTimeline( + call, + 'finalize_begin', + `reason=${localReasonCode || ''}; debug=${debugReason || ''}; phase=${call.phase || ''}`, + String(localReasonCode || '') === 'completed' ? 'info' : 'warn', + ); const diagnosticsBeforeClose = getCallDiagnosticsContext(call); cleanupTimers(call); stopReconnectFlow(call); stopTone(); + if (String(debugReason || '') === 'remote_hangup' && call.direction === 'in') { + dismissCallUiLocally(call); + } const shouldNotifyRemoteFailure = !suppressRemoteSignal @@ -902,7 +1097,7 @@ async function finalizeCall(call, { } if (!suppressSummary) { - pushCallSummary(call, localReasonCode); + await sendOutgoingCallSummaryDm(call, localReasonCode); } call.phase = 'ended'; @@ -910,6 +1105,7 @@ async function finalizeCall(call, { if (String(localReasonCode || '') === 'busy') { call.statusText = 'Пользователь занят'; } + recordCallTimeline(call, 'finalize_end', `reason=${localReasonCode || ''}; status=${call.statusText || ''}`); notifyCallState(); const finalHoldMs = String(localReasonCode || '') === 'busy' ? 2600 : 0; @@ -945,6 +1141,7 @@ async function emitDebug(call, level, message, details = '') { async function sendSignal(call, type, data = '') { if (!call.remoteSessionId) return; + const signalName = getSignalTypeName(type); try { await authService.callSignalToSession({ toLogin: call.peerLogin, @@ -953,8 +1150,18 @@ async function sendSignal(call, type, data = '') { type, data, }); + recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`); await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`); } catch (error) { + if (String(error?.code || '').toUpperCase() === 'SESSION_NOT_FOUND') { + recordCallTimeline( + call, + `signal_out_${signalName}_session_not_found`, + `toSession=${call.remoteSessionId}; op=${String(error?.op || '')}; status=${String(error?.status || '')}`, + 'warn', + ); + } + recordCallTimeline(call, `signal_out_${signalName}_failed`, `toSession=${call.remoteSessionId}; error=${toErrorText(error)}`, 'warn'); await emitDebug(call, 'error', `signal_send_failed_${type}`, toErrorText(error)); throw error; } @@ -993,6 +1200,14 @@ async function ensurePeerConnection(call) { pc.onicecandidate = async (event) => { if (!event.candidate || !call.remoteSessionId) return; + call.localIceSentCount = Number(call.localIceSentCount || 0) + 1; + if (call.localIceSentCount <= 3) { + recordCallTimeline( + call, + 'ice_local_candidate', + `count=${call.localIceSentCount}; type=${event.candidate?.type || ''}; protocol=${event.candidate?.protocol || ''}`, + ); + } try { await sendSignal(call, TYPES.ICE, JSON.stringify(event.candidate)); } catch {} @@ -1000,8 +1215,18 @@ async function ensurePeerConnection(call) { pc.onconnectionstatechange = () => { const state = pc.connectionState; + recordCallTimeline( + call, + 'pc_connection_state', + `state=${state || ''}; ice=${pc.iceConnectionState || ''}; signal=${pc.signalingState || ''}`, + state === 'failed' ? 'warn' : 'info', + ); if (state === 'connected') { stopReconnectFlow(call); + if (call.timers?.incomingConnect20s) { + clearTimeout(call.timers.incomingConnect20s); + call.timers.incomingConnect20s = null; + } if (!call.connectedAtMs) { call.connectedAtMs = nowMs(); } @@ -1070,6 +1295,7 @@ async function ensurePeerConnection(call) { call.audioSenders = []; call.connectionRouteLabel = ''; call.connectionRouteDetails = ''; + recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`); stream.getTracks().forEach((track) => { track.enabled = track.kind === 'audio' ? !call.muted : true; const sender = pc.addTrack(track, stream); @@ -1079,11 +1305,13 @@ async function ensurePeerConnection(call) { }); } catch (e) { setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed'); + recordCallTimeline(call, 'local_media_failed', toErrorText(e), 'warn'); await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e)); throw e; } pc.ontrack = (evt) => { + recordCallTimeline(call, 'remote_track_received', `streams=${evt?.streams?.length || 0}`); const audio = new Audio(); audio.autoplay = true; audio.srcObject = evt.streams[0]; @@ -1103,6 +1331,7 @@ async function onAccept(call) { await emitDebug(call, 'warn', 'accept_duplicate_ignored', `phase=${call.phase || ''}`); return; } + recordCallTimeline(call, 'offer_start', `remoteSession=${call.remoteSessionId || ''}`); call.initialOfferInProgress = true; cleanupTimers(call); setStatus(call, 'Соединяем…', 'connecting'); @@ -1192,10 +1421,14 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) { pendingRemoteIceCandidates: [], initialOfferInProgress: false, initialOfferSent: false, + timelineEvents: [], + timelineSeq: 0, + timelineBaseMs: nowMs(), }; calls.set(callId, call); + recordCallTimeline(call, 'incoming_invite_created', `from=${fromLogin}; session=${fromSessionId}; source=${source}`); } else if (!call.remoteSessionId && fromSessionId) { - call.remoteSessionId = fromSessionId; + setRemoteSessionId(call, fromSessionId, 'incoming_invite_existing_call'); } activeCallId = callId; @@ -1351,10 +1584,14 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin pendingRemoteIceCandidates: [], initialOfferInProgress: false, initialOfferSent: false, + timelineEvents: [], + timelineSeq: 0, + timelineBaseMs: nowMs(), }; calls.set(cleanCallId, call); activeCallId = cleanCallId; + recordCallTimeline(call, 'debug_initiator_created', `peer=${cleanPeerLogin}; session=${cleanPeerSessionId}`); notifyCallState(); await emitDebug(call, 'info', 'debug_start_initiator', `peerSessionId=${cleanPeerSessionId}`); try { @@ -1404,35 +1641,47 @@ export async function startOutgoingCall(peerLogin) { pendingRemoteIceCandidates: [], initialOfferInProgress: false, initialOfferSent: false, + timelineEvents: [], + timelineSeq: 0, + timelineBaseMs: nowMs(), }; calls.set(callId, call); activeCallId = callId; + recordCallTimeline(call, 'outgoing_created', `peer=${cleanPeer}`); setStatus(call, 'Ищем пользователя…', 'searching'); - call.timers.ack10s = setTimeout(() => { - if (!calls.has(callId)) return; - if (call.phase === 'searching') { - void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'no_ack_10s' }); - } - }, 10000); - call.timers.total35s = setTimeout(() => { if (!calls.has(callId)) return; if (!call.connectedAtMs) { + recordCallTimeline(call, 'total_timeout_fired', 'total_timeout_35s', 'warn'); void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'total_timeout_35s' }); } }, 35000); try { - await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + recordCallTimeline( + call, + 'invite_broadcast_ok', + `ws=${Number(call.inviteDelivery?.deliveredWsSessions || 0)}; push=${Number(call.inviteDelivery?.deliveredWebPushSessions || call.inviteDelivery?.deliveredFcmSessions || 0)}`, + ); + if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') { + setStatus(call, 'Вызываем…', 'ringing'); + } + scheduleOutgoingAckTimeout(call); } catch (error) { + recordCallTimeline(call, 'invite_broadcast_failed', toErrorText(error), 'warn'); const text = String(error?.message || '').toUpperCase(); const isNotAuth = text.includes('NOT_AUTHENTICATED'); if (isNotAuth) { const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true }); if (recovered) { try { - await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') { + setStatus(call, 'Вызываем…', 'ringing'); + } + scheduleOutgoingAckTimeout(call); return; } catch {} } @@ -1451,9 +1700,11 @@ export async function handleIncomingCallInvite(evt) { export async function acceptIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; + recordCallTimeline(call, 'incoming_accept_clicked', `session=${call.remoteSessionId || ''}`); call.phase = 'connecting'; setStatus(call, 'Соединяем…', 'connecting'); cleanupTimers(call); + scheduleIncomingConnectTimeout(call); await sendSignal(call, TYPES.ACCEPT, 'accept'); } @@ -1485,12 +1736,15 @@ export async function handleIncomingCallSignal(evt) { const call = getCall(callId); if (!call) return; + const signalName = getSignalTypeName(type); + recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`); if (call.direction === 'out') { if (type === TYPES.RINGING) { if (!call.remoteSessionId && fromSessionId) { - call.remoteSessionId = fromSessionId; + setRemoteSessionId(call, fromSessionId, 'ringing'); } if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) { + recordCallTimeline(call, 'signal_in_ringing_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`); await emitDebug( call, 'info', @@ -1502,8 +1756,9 @@ export async function handleIncomingCallSignal(evt) { } else if (type === TYPES.ACCEPT) { if (fromSessionId) { if (!call.remoteSessionId || !call.initialOfferSent) { - call.remoteSessionId = fromSessionId; + setRemoteSessionId(call, fromSessionId, 'accept_before_offer_lock'); } else if (call.remoteSessionId !== fromSessionId) { + recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn'); await emitDebug( call, 'warn', @@ -1528,6 +1783,7 @@ export async function handleIncomingCallSignal(evt) { && (type === TYPES.DECLINE_BUSY || type === TYPES.TIMEOUT || type === TYPES.HANGUP); if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) { if (terminalSignalFromAnotherSession && !remoteSessionLocked) { + recordCallTimeline(call, 'terminal_signal_before_lock_allowed', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`); await emitDebug( call, 'info', @@ -1535,6 +1791,7 @@ export async function handleIncomingCallSignal(evt) { `type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`, ); } else { + recordCallTimeline(call, 'signal_in_ignored_non_selected_session', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`); await emitDebug( call, 'info', @@ -1545,11 +1802,11 @@ export async function handleIncomingCallSignal(evt) { } } if (!call.remoteSessionId && fromSessionId) { - call.remoteSessionId = fromSessionId; + setRemoteSessionId(call, fromSessionId, `first_${signalName}`); } } } else if (!call.remoteSessionId) { - call.remoteSessionId = fromSessionId; + setRemoteSessionId(call, fromSessionId, `incoming_${signalName}`); } if (type === TYPES.RINGING) { @@ -1595,6 +1852,7 @@ export async function handleIncomingCallSignal(evt) { if (type === TYPES.OFFER) { try { + recordCallTimeline(call, 'offer_processing_start', `from=${fromSessionId || '-'}`); const pc = await ensurePeerConnection(call); await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); await flushPendingIceCandidates(call); @@ -1632,6 +1890,7 @@ export async function handleIncomingCallSignal(evt) { } await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); await flushPendingIceCandidates(call); + recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`); setStatus(call, 'Соединяем…', 'connecting'); await emitDebug(call, 'info', 'answer_processed', 'remote description set'); } catch (error) { @@ -1644,9 +1903,13 @@ export async function handleIncomingCallSignal(evt) { if (type === TYPES.ICE) { try { const candidate = JSON.parse(data); + call.remoteIceReceivedCount = Number(call.remoteIceReceivedCount || 0) + 1; if (!call.pc) { if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = []; call.pendingRemoteIceCandidates.push(candidate); + if (call.remoteIceReceivedCount <= 3) { + recordCallTimeline(call, 'ice_remote_queued_no_pc', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`); + } await emitDebug(call, 'info', 'ice_queued_before_pc', `queue=${call.pendingRemoteIceCandidates.length}`); return; } @@ -1654,10 +1917,16 @@ export async function handleIncomingCallSignal(evt) { if (!pc.remoteDescription) { if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = []; call.pendingRemoteIceCandidates.push(candidate); + if (call.remoteIceReceivedCount <= 3) { + recordCallTimeline(call, 'ice_remote_queued_no_remote_description', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`); + } await emitDebug(call, 'info', 'ice_queued_before_remote_description', `queue=${call.pendingRemoteIceCandidates.length}`); return; } await pc.addIceCandidate(new RTCIceCandidate(candidate)); + if (call.remoteIceReceivedCount <= 3) { + recordCallTimeline(call, 'ice_remote_applied', `count=${call.remoteIceReceivedCount}`); + } await emitDebug(call, 'info', 'ice_processed', 'candidate added'); } catch (error) { await emitDebug(call, 'error', 'ice_process_failed', toErrorText(error)); @@ -1669,6 +1938,7 @@ export async function hangupActiveCall() { if (!activeCallId) return; const call = getCall(activeCallId); dismissCallUiLocally(call); + recordCallTimeline(call, 'hangup_clicked', `connected=${Boolean(call?.connectedAtMs)}`); await finalizeCall(call, { localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer', debugReason: 'hangup_by_user', @@ -1695,6 +1965,7 @@ export async function handleStopCallPush(payload = {}) { await emitDebug(call, 'info', 'stop_call_push_ignored_for_origin_session', reason); return; } + recordCallTimeline(call, 'stop_call_push', `fromSession=${fromSessionId || '-'}; reason=${reason}`); await finalizeCall(call, { localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer', debugReason: `stop_call_push:${reason}`, diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index 1d1b6d6..b5bb051 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -1,6 +1,7 @@ const encoder = new TextEncoder(); const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.'; import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js'; +import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519'; const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key'; function getCryptoApi() { @@ -340,3 +341,56 @@ export async function signBytes(privateKey, bytes) { const signature = await getSubtleApi().sign({ name: 'Ed25519' }, privateKey, bytes); return new Uint8Array(signature); } + +export function extractEd25519SeedFromPkcs8B64(pkcs8B64) { + const bytes = base64ToBytes(String(pkcs8B64 || '').trim()); + if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ client.key'); + return bytes.slice(bytes.length - 32); +} + +export function ed25519SeedToX25519Private(seed32) { + const seed = seed32 instanceof Uint8Array ? seed32 : new Uint8Array(seed32 || []); + if (seed.length !== 32) throw new Error('Seed Ed25519 должен быть 32 байта'); + return new Uint8Array(edwardsToMontgomeryPriv(seed)); +} + +export function ed25519PublicToX25519Public(ed25519Pub32) { + const pub = ed25519Pub32 instanceof Uint8Array ? ed25519Pub32 : new Uint8Array(ed25519Pub32 || []); + if (pub.length !== 32) throw new Error('Публичный Ed25519 ключ должен быть 32 байта'); + return new Uint8Array(edwardsToMontgomeryPub(pub)); +} + +export function x25519RandomPrivateKey() { + return new Uint8Array(x25519.utils.randomPrivateKey()); +} + +export function x25519PublicFromPrivate(privateKey32) { + const key = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []); + if (key.length !== 32) throw new Error('Приватный X25519 ключ должен быть 32 байта'); + return new Uint8Array(x25519.getPublicKey(key)); +} + +export function x25519SharedSecret(privateKey32, publicKey32) { + const priv = privateKey32 instanceof Uint8Array ? privateKey32 : new Uint8Array(privateKey32 || []); + const pub = publicKey32 instanceof Uint8Array ? publicKey32 : new Uint8Array(publicKey32 || []); + if (priv.length !== 32 || pub.length !== 32) { + throw new Error('X25519 ключи должны быть по 32 байта'); + } + return new Uint8Array(x25519.getSharedSecret(priv, pub)); +} + +export async function hkdfSha256(ikmBytes, saltBytes, infoBytes, outLen) { + const subtle = getSubtleApi(); + const baseKey = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveBits']); + const bits = await subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: saltBytes instanceof Uint8Array ? saltBytes : new Uint8Array(saltBytes || []), + info: infoBytes instanceof Uint8Array ? infoBytes : new Uint8Array(infoBytes || []), + }, + baseKey, + Number(outLen) * 8, + ); + return new Uint8Array(bits); +} diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js new file mode 100644 index 0000000..841a235 --- /dev/null +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -0,0 +1,144 @@ +function defaultParsed(rawText = '') { + const text = String(rawText || ''); + return { + rawText: text, + prefixText: '', + visibleText: text, + displayText: text, + blocks: [], + replyRef: null, + callSummary: null, + }; +} + +function parseReplyId(value = '') { + const parts = String(value || '').split('|'); + if (parts.length !== 4) return null; + const fromLogin = String(parts[0] || '').trim(); + const toLogin = String(parts[1] || '').trim(); + const timeMs = Number(parts[2] || 0); + const nonce = Number(parts[3] || 0); + if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || timeMs <= 0 || !Number.isFinite(nonce) || nonce < 0) { + return null; + } + return { + fromLogin, + toLogin, + timeMs, + nonce, + baseKey: `${fromLogin}|${toLogin}|${timeMs}|${nonce}`, + }; +} + +function formatCallDuration(totalSeconds) { + const total = Math.max(0, Math.floor(Number(totalSeconds || 0))); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function buildCallDisplayText(callSummary) { + if (!callSummary) return ''; + if (callSummary.status === 'completed') { + return `Звонок: ${formatCallDuration(callSummary.durationSec)}`; + } + const reason = String(callSummary.reason || '').trim().toLowerCase(); + if (reason === 'offline') return 'Звонил, но недозвонился: абонент не в сети'; + if (reason === 'no_answer') return 'Звонил, но недозвонился: нет ответа'; + if (reason === 'connect_failed') return 'Звонил, но недозвонился: не удалось установить соединение'; + if (reason === 'busy') return 'Звонил, но недозвонился: абонент занят'; + if (reason === 'declined') return 'Звонил, но недозвонился: звонок отклонён'; + return 'Звонил, но недозвонился'; +} + +export function sanitizeUserDmTextForSend(rawText = '') { + const text = String(rawText || ''); + return text.startsWith('`; + } + const cleanReason = String(reason || '').trim().toLowerCase(); + return ``; +} + +export function buildDmReplyTechBlock({ baseKey = '' } = {}) { + const cleanBaseKey = String(baseKey || '').trim(); + if (!cleanBaseKey) return ''; + return ``; +} + +export function parseDmTechBlocks(rawText = '') { + const text = String(rawText || ''); + if (!text.startsWith('', cursor); + if (end < 0) { + if (!blocks.length) return defaultParsed(text); + break; + } + + const inner = text.slice(cursor + 7, end); + const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean); + if (!segments.length) { + if (!blocks.length) return defaultParsed(text); + break; + } + + const kind = String(segments[0] || '').trim().toLowerCase(); + const fields = {}; + for (let i = 1; i < segments.length; i += 1) { + const part = segments[i]; + const eq = part.indexOf('='); + if (eq <= 0) continue; + const key = part.slice(0, eq).trim().toLowerCase(); + const value = part.slice(eq + 1).trim(); + if (key) fields[key] = value; + } + + const block = { kind, version: Number(fields.v || 0), fields }; + blocks.push(block); + + if (kind === 'reply' && !replyRef) { + replyRef = parseReplyId(fields.id || ''); + } else if (kind === 'call' && !callSummary) { + const status = String(fields.status || '').trim().toLowerCase(); + if (status === 'completed') { + const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0))); + callSummary = { status: 'completed', durationSec }; + } else { + callSummary = { + status: 'failed', + reason: String(fields.reason || '').trim().toLowerCase(), + }; + } + } + + cursor = end + 1; + } + + const visibleText = text.slice(cursor); + const displayText = callSummary ? buildCallDisplayText(callSummary) : visibleText; + return { + rawText: text, + prefixText: text.slice(0, cursor), + visibleText, + displayText, + blocks, + replyRef, + callSummary, + }; +} diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js index 33acae7..843c6b5 100644 --- a/shine-UI/js/services/shine-user-pda-service.js +++ b/shine-UI/js/services/shine-user-pda-service.js @@ -1,4 +1,4 @@ -import { base64ToBytes, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; +import { base58ToBytes, base64ToBytes, bytesToBase58, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; import { extractSeed32FromPkcs8B64 } from './client-key-utils.js'; import { SHINE_LOGIN_GUARD_PROGRAM_ID, @@ -6,15 +6,18 @@ import { SHINE_USERS_ECONOMY_CONFIG_SEED, SHINE_USERS_PROGRAM_ID, } from '../solana-programs.js'; +import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; const MAGIC = 'SHiNE'; const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK'; const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault'; const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login='; +const SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX = 'promo_seller='; const LIMIT_STEP = 10_000n; const BLOCKCHAIN_TYPE_MAIN_USER = 1; const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111'; const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111'; +const PROMO_SIGN_PREFIX = 'shine_promo_v1:'; const BLOCK_TYPE_RECOVERY_KEY = 0; const BLOCK_TYPE_ROOT_KEY = 1; @@ -30,7 +33,7 @@ const SESSION_TYPE_HOMESERVER = 100; let solanaLibPromise = null; function loadSolanaLib() { - if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); + if (!solanaLibPromise) solanaLibPromise = loadSolanaWeb3(); return solanaLibPromise; } @@ -152,11 +155,12 @@ function parseUsersEconomyConfig(dataBytes) { }; } -function buildEd25519IxData(sig64, pubkey32, msgHash32) { +function buildEd25519IxData(sig64, pubkey32, messageBytes) { const sigOff = 16; const pkOff = sigOff + 64; const msgOff = pkOff + 32; - const data = new Uint8Array(msgOff + 32); + const msg = messageBytes instanceof Uint8Array ? messageBytes : new Uint8Array(messageBytes || []); + const data = new Uint8Array(msgOff + msg.length); const view = new DataView(data.buffer); data[0] = 1; data[1] = 0; @@ -165,11 +169,11 @@ function buildEd25519IxData(sig64, pubkey32, msgHash32) { view.setUint16(6, pkOff, true); view.setUint16(8, 0xffff, true); view.setUint16(10, msgOff, true); - view.setUint16(12, 32, true); + view.setUint16(12, msg.length, true); view.setUint16(14, 0xffff, true); data.set(sig64, sigOff); data.set(pubkey32, pkOff); - data.set(msgHash32, msgOff); + data.set(msg, msgOff); return data; } @@ -213,6 +217,10 @@ function serializeCreateUserPdaArgs(args) { } buf.push(Number(args.trustedCount || 0) & 0xff); for (const x of args.rootSignature64) buf.push(x); + const promoSellerLogin = normalizeLogin(String(args.promoSellerLogin || '').trim()); + if (promoSellerLogin) { + pushStrU8(buf, promoSellerLogin); + } return new Uint8Array(buf); } @@ -362,6 +370,37 @@ export function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber return new Uint8Array(buf); } +export function buildPromoMessageBytes(login) { + return new TextEncoder().encode(`${PROMO_SIGN_PREFIX}${normalizeLogin(login)}`); +} + +export function buildPromoCodeString(sellerLogin, signatureBytes) { + const cleanSellerLogin = normalizeLogin(sellerLogin); + if (!cleanSellerLogin) { + throw new Error('Не указан логин продавца'); + } + const sig = signatureBytes instanceof Uint8Array ? signatureBytes : new Uint8Array(signatureBytes || []); + if (sig.length !== 64) { + throw new Error('Подпись промокода должна быть ровно 64 байта'); + } + return `1${cleanSellerLogin}-${bytesToBase58(sig)}`; +} + +export async function findPromoSellerPda({ sellerLogin }) { + const cleanSellerLogin = normalizeLogin(sellerLogin); + if (!cleanSellerLogin) throw new Error('Не указан логин продавца'); + const solana = await loadSolanaLib(); + const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); + const [promoSellerPda] = solana.PublicKey.findProgramAddressSync( + [new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanSellerLogin)], + usersProgram, + ); + return { + sellerLogin: cleanSellerLogin, + promoSellerPda: promoSellerPda.toBase58(), + }; +} + export function parseShineUserPda(dataBytes) { const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []); const reader = makeReader(bytes); @@ -769,6 +808,7 @@ async function createShineUserPdaOnSolana({ serverAddress = '', syncServers = [], accessServers = [], + promoCode = '', }) { const ctx = await buildCreateContext({ login, keyBundle, solanaEndpoint }); const ecoAccount = await ctx.connection.getAccountInfo(ctx.economyConfigPda); @@ -777,6 +817,7 @@ async function createShineUserPdaOnSolana({ } const cleanLogin = ctx.cleanLogin; + const cleanPromoCode = String(promoCode || '').trim(); const blockchainName = `${cleanLogin}-001`; const zeroHash32 = new Uint8Array(32); const createdAtMs = BigInt(Date.now()); @@ -820,6 +861,39 @@ async function createShineUserPdaOnSolana({ const unsignedHash = await sha256Bytes(unsignedRecord); const rootSig64 = await signBytes(ctx.rootPrivKey, unsignedHash); + let promoSellerPda = null; + let promoEd25519Ix = null; + let promoSellerLogin = ''; + if (cleanPromoCode) { + const dashPos = cleanPromoCode.indexOf('-'); + if (!cleanPromoCode.startsWith('1') || dashPos <= 1 || dashPos === cleanPromoCode.length - 1) { + throw new Error('Некорректный формат промокода'); + } + const sellerLogin = normalizeLogin(cleanPromoCode.slice(1, dashPos)); + const signatureBase58 = cleanPromoCode.slice(dashPos + 1); + promoSellerLogin = sellerLogin; + const [promoSellerAddress] = ctx.solana.PublicKey.findProgramAddressSync( + [new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(sellerLogin)], + ctx.usersProgram, + ); + promoSellerPda = promoSellerAddress; + + const promoMessage = buildPromoMessageBytes(cleanLogin); + const signatureBytes = base58ToBytes(signatureBase58); + if (signatureBytes.length !== 64) { + throw new Error('Подпись промокода должна быть 64 байта в Base58'); + } + const sellerAccount = await ctx.connection.getAccountInfo(promoSellerAddress, 'confirmed'); + if (!sellerAccount?.data || sellerAccount.data.length < 42) { + throw new Error('Promo seller PDA не найдена или повреждена'); + } + const signerPubkey = new Uint8Array(sellerAccount.data.slice(10, 42)); + promoEd25519Ix = new ctx.solana.TransactionInstruction({ + programId: ctx.ed25519Program, + keys: [], + data: buildEd25519IxData(signatureBytes, signerPubkey, promoMessage), + }); + } const ixData = serializeCreateUserPdaArgs({ login: cleanLogin, recoveryKey32: ctx.recoveryKey32, @@ -844,6 +918,7 @@ async function createShineUserPdaOnSolana({ sessions: [], trustedCount: 0, rootSignature64: rootSig64, + promoSellerLogin, }); const ed25519RootIx = new ctx.solana.TransactionInstruction({ @@ -866,15 +941,19 @@ async function createShineUserPdaOnSolana({ { pubkey: ctx.sysvarInstructions, isSigner: false, isWritable: false }, { pubkey: ctx.economyConfigPda, isSigner: false, isWritable: false }, { pubkey: ctx.loginGuardProgram, isSigner: false, isWritable: false }, + ...(promoSellerPda ? [{ pubkey: promoSellerPda, isSigner: false, isWritable: true }] : []), ], data: ixData, }); let signature; try { + const tx = new ctx.solana.Transaction(); + if (promoEd25519Ix) tx.add(promoEd25519Ix); + tx.add(ed25519RootIx, ed25519BchIx, createIx); signature = await ctx.solana.sendAndConfirmTransaction( ctx.connection, - new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx), + tx, [ctx.clientKeypair], { commitment: 'confirmed' }, ); @@ -890,13 +969,14 @@ async function createShineUserPdaOnSolana({ }; } -export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [] }) { +export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [], promoCode = '' }) { return createShineUserPdaOnSolana({ login, keyBundle, solanaEndpoint, isServer: false, accessServers: Array.isArray(accessServers) ? accessServers : [], + promoCode, }); } diff --git a/shine-UI/js/services/solana-register-service.js b/shine-UI/js/services/solana-register-service.js index 6998d2c..3c91fef 100644 --- a/shine-UI/js/services/solana-register-service.js +++ b/shine-UI/js/services/solana-register-service.js @@ -3,14 +3,15 @@ import { SHINE_USERS_PROGRAM_ID, SHINE_LOGIN_GUARD_PROGRAM_ID, } from '../solana-programs.js'; +import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; const CLASSIFY_LOGIN_INSTRUCTION_TAG = 1; -const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P'; +const PRECHECK_SIM_PAYER = 'SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh'; const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login='; let solanaLibPromise = null; function loadSolanaLib() { - if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); + if (!solanaLibPromise) solanaLibPromise = loadSolanaWeb3(); return solanaLibPromise; } @@ -138,6 +139,6 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) { return { exists: !!ai, userPda: userPda.toBase58() }; } -export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers }) { - return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint, accessServers }); +export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers, promoCode = '' }) { + return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint, accessServers, promoCode }); } diff --git a/shine-UI/js/services/solana-wallet-service.js b/shine-UI/js/services/solana-wallet-service.js index e0d2da3..defdc92 100644 --- a/shine-UI/js/services/solana-wallet-service.js +++ b/shine-UI/js/services/solana-wallet-service.js @@ -1,6 +1,7 @@ import { extractClientKey32FromStoredValue } from './client-key-utils.js'; import { loadEncryptedUserSecrets } from './key-vault.js'; import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js'; +import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT; const TOPUP_SITE_URL = '/devnet-topup-view'; @@ -24,7 +25,7 @@ function normalizeEndpoint(url) { async function loadSolanaLib() { if (!solanaLibPromise) { - solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); + solanaLibPromise = loadSolanaWeb3(); } return solanaLibPromise; } diff --git a/shine-UI/js/solana-programs.js b/shine-UI/js/solana-programs.js index 922af48..7654ab0 100644 --- a/shine-UI/js/solana-programs.js +++ b/shine-UI/js/solana-programs.js @@ -1,9 +1,12 @@ -export const SOLANA_CLUSTER = 'devnet'; -export const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com'; +import { defaultSolanaCluster, defaultSolanaEndpoint } from './deploy-config.js'; + +export const SOLANA_CLUSTER = defaultSolanaCluster; +// Для браузерного UI endpoint выбирается на уровне deploy-config.js конкретного инстанса. +export const SOLANA_ENDPOINT_DEFAULT = defaultSolanaEndpoint; // Единый файл актуальных Solana Program ID для UI-части SHiNE. // При смене адресов обновлять этот файл и все его потребители внутри shine-UI. -export const SHINE_USERS_PROGRAM_ID = '3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ'; +export const SHINE_USERS_PROGRAM_ID = 'SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6'; export const SHINE_USERS_ECONOMY_CONFIG_SEED = 'shine_users_economy_config'; -export const SHINE_LOGIN_GUARD_PROGRAM_ID = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo'; -export const SHINE_PAYMENTS_PROGRAM_ID = 'c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW'; +export const SHINE_LOGIN_GUARD_PROGRAM_ID = 'SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh'; +export const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v'; diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index eebaa04..d0e4580 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -97,6 +97,21 @@ const DEFAULT_ARWEAVE_SERVER = 'https://arweave.net'; const DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS = 6000; const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'; +function normalizeStoredSolanaServer(value) { + const raw = String(value || '').trim(); + if (!raw) return DEFAULT_SOLANA_SERVER; + const normalized = raw.replace(/\/+$/u, '').toLowerCase(); + if ( + normalized === 'https://api.devnet.solana.com' + || normalized === 'http://api.devnet.solana.com' + || normalized === 'https://api.mainnet-beta.solana.com' + || normalized === 'http://api.mainnet-beta.solana.com' + ) { + return DEFAULT_SOLANA_SERVER; + } + return raw; +} + export function normalizeDmChatId(value) { return String(value || '').trim().toLowerCase(); } @@ -187,12 +202,13 @@ function persistEntrySettings(settings) { try { const payload = { language: String(settings?.language || 'ru'), - solanaServer: String(settings?.solanaServer || DEFAULT_SOLANA_SERVER), + solanaServer: normalizeStoredSolanaServer(settings?.solanaServer), shineServer: String(settings?.shineServer || DEFAULT_SHINE_SERVER), shineServerLogin: String(settings?.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE), shineServerHttp: String(settings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE), arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(settings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)), + remoteAddBlockSessionId: String(settings?.remoteAddBlockSessionId || ''), statuses: { solanaServer: String(settings?.statuses?.solanaServer || 'idle'), shineServerLogin: String(settings?.statuses?.shineServerLogin || settings?.statuses?.shineServer || 'idle'), @@ -242,6 +258,8 @@ function createInitialState({ withStoredSession = true } = {}) { appLog: [], incomingDedup: {}, knownMessageKeys: {}, + pendingOutgoingReadByBaseKey: {}, + pendingIncomingReadByBaseKey: {}, outgoingTempSeq: 1, notificationsTab: 'replies', pageLabelCollapsed: false, @@ -255,12 +273,13 @@ function createInitialState({ withStoredSession = true } = {}) { startHint: '', entrySettings: { language: String(storedEntrySettings?.language || 'ru'), - solanaServer: String(storedEntrySettings?.solanaServer || DEFAULT_SOLANA_SERVER), + solanaServer: normalizeStoredSolanaServer(storedEntrySettings?.solanaServer), shineServer: String(LOCAL_WS_OVERRIDE_URL || storedEntrySettings?.shineServer || initialShineServer), shineServerLogin: String(storedEntrySettings?.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE), shineServerHttp: String(storedEntrySettings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE), arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER), callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(storedEntrySettings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)), + remoteAddBlockSessionId: String(storedEntrySettings?.remoteAddBlockSessionId || ''), statuses: { solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'), shineServerLogin: String(storedEntrySettings?.statuses?.shineServerLogin || storedEntrySettings?.statuses?.shineServer || 'idle'), @@ -274,6 +293,9 @@ function createInitialState({ withStoredSession = true } = {}) { password: '', passwordMode: 'single', passwordWords: emptyPasswordWords(), + passwordWordsLinked: false, + usePromoCode: false, + promoCode: '', sessionId: '', storagePwd: '', pendingKeyBundle: null, @@ -297,7 +319,7 @@ function createInitialState({ withStoredSession = true } = {}) { rootKey: 'Ключ root хранится в зашифрованном виде', blockchainKey: 'Ключ blockchain хранится в зашифрованном виде', clientKey: 'Ключ device хранится в зашифрованном виде', - saveRoot: false, + saveRoot: true, saveBlockchain: true, saveDevice: true, }, @@ -323,6 +345,11 @@ function createInitialState({ withStoredSession = true } = {}) { export const state = createInitialState(); export const authService = new AuthService(state.entrySettings.shineServer); +authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId); +authService.setActiveSessionContext({ + login: state.session.login, + sessionId: state.session.sessionId, +}); let onSessionReset = null; let onSessionAuthorized = null; @@ -541,32 +568,48 @@ export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) export function markOutgoingReadByBaseKey(baseKey) { if (!baseKey) return; const keys = Object.keys(state.chats || {}); + let matched = false; keys.forEach((chatId) => { const list = getChatMessages(chatId); list.forEach((row) => { if (row?.from !== 'out') return; if (row.baseKey === baseKey) { + matched = true; row.secondTick = true; persistMessageRecord(chatId, row); } }); }); + if (matched) { + delete state.pendingOutgoingReadByBaseKey[baseKey]; + } else { + state.pendingOutgoingReadByBaseKey[baseKey] = true; + } + return matched; } export function markIncomingReadByBaseKey(baseKey) { if (!baseKey) return; const keys = Object.keys(state.chats || {}); + let matched = false; keys.forEach((chatId) => { const list = getChatMessages(chatId); list.forEach((row) => { if (row?.from !== 'in') return; if (row.baseKey === baseKey) { + matched = true; row.unread = false; row.readReceiptSent = true; persistMessageRecord(chatId, row); } }); }); + if (matched) { + delete state.pendingIncomingReadByBaseKey[baseKey]; + } else { + state.pendingIncomingReadByBaseKey[baseKey] = true; + } + return matched; } export function markReadReceiptSentByBaseKey(baseKey) { @@ -634,6 +677,15 @@ export function addSignedMessageToChat({ row.firstTick = row.from === 'out'; row.secondTick = Boolean(existing?.secondTick); row.readReceiptSent = Boolean(existing?.readReceiptSent); + if (row.baseKey && row.from === 'out' && state.pendingOutgoingReadByBaseKey[row.baseKey]) { + row.secondTick = true; + delete state.pendingOutgoingReadByBaseKey[row.baseKey]; + } + if (row.baseKey && row.from === 'in' && state.pendingIncomingReadByBaseKey[row.baseKey]) { + row.unread = false; + row.readReceiptSent = true; + delete state.pendingIncomingReadByBaseKey[row.baseKey]; + } if (existingIndex < 0) { list.push(row); @@ -643,15 +695,57 @@ export function addSignedMessageToChat({ return true; } +export function deleteSignedMessageByBaseKey(chatId, baseKey) { + const normalizedChatId = normalizeDmChatId(chatId); + const normalizedBaseKey = String(baseKey || '').trim(); + if (!normalizedChatId || !normalizedBaseKey) return false; + const list = getChatMessages(normalizedChatId); + let changed = false; + for (let i = list.length - 1; i >= 0; i -= 1) { + const row = list[i]; + if (String(row?.baseKey || '').trim() !== normalizedBaseKey) continue; + changed = true; + removeStoredMessageRecord(String(row?.messageKey || '').trim()); + list.splice(i, 1); + } + if (changed) { + sortChatMessagesInPlace(normalizedChatId); + } + return changed; +} + +export function deleteConversationMessagesBefore(chatId, boundaryTimeMs) { + const normalizedChatId = normalizeDmChatId(chatId); + const boundary = Number(boundaryTimeMs || 0); + if (!normalizedChatId || !Number.isFinite(boundary) || boundary <= 0) return 0; + const list = getChatMessages(normalizedChatId); + let removed = 0; + for (let i = list.length - 1; i >= 0; i -= 1) { + const row = list[i]; + const rowTime = resolveChatMessageTimeMs(row); + if (!Number.isFinite(rowTime) || rowTime >= boundary) continue; + removed += 1; + removeStoredMessageRecord(String(row?.messageKey || '').trim()); + list.splice(i, 1); + } + if (removed > 0) { + sortChatMessagesInPlace(normalizedChatId); + } + return removed; +} + export function markChatRead(chatId) { const normalizedChatId = normalizeDmChatId(chatId); const list = getChatMessages(normalizedChatId); + let changed = 0; list.forEach((row) => { - if (row?.from === 'in') { + if (row?.from === 'in' && row?.unread) { row.unread = false; persistMessageRecord(normalizedChatId, row); + changed += 1; } }); + return changed; } export function setContacts(list) { @@ -716,7 +810,7 @@ export function checkServerAvailability(address) { } export async function saveEntrySettings(nextSettings) { - const nextSolanaServer = String(nextSettings?.solanaServer || state.entrySettings.solanaServer || DEFAULT_SOLANA_SERVER); + const nextSolanaServer = normalizeStoredSolanaServer(nextSettings?.solanaServer || state.entrySettings.solanaServer || DEFAULT_SOLANA_SERVER); const nextShineServerLogin = String(nextSettings?.shineServerLogin || state.entrySettings.shineServerLogin || DEFAULT_SHINE_SERVER_LOGIN_VALUE).trim().toLowerCase() || DEFAULT_SHINE_SERVER_LOGIN_VALUE; let forcedShineServer = LOCAL_WS_OVERRIDE_URL || ''; @@ -745,6 +839,7 @@ export async function saveEntrySettings(nextSettings) { tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools), }; persistEntrySettings(state.entrySettings); + authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId); await authService.reconnect(state.entrySettings.shineServer); state.startHint = `Настройки входа сохранены. SHiNE: ${state.entrySettings.shineServerHttp}`; } @@ -788,6 +883,7 @@ export function authorizeSession({ login, sessionId, }); + authService.setActiveSessionContext({ login, sessionId }); state.startHint = ''; if (onSessionAuthorized) { onSessionAuthorized(); @@ -833,6 +929,8 @@ function resetStateForSignedOut() { state.appLog = next.appLog; state.incomingDedup = next.incomingDedup; state.knownMessageKeys = next.knownMessageKeys; + state.pendingOutgoingReadByBaseKey = next.pendingOutgoingReadByBaseKey; + state.pendingIncomingReadByBaseKey = next.pendingIncomingReadByBaseKey; state.outgoingTempSeq = next.outgoingTempSeq; state.notificationsTab = next.notificationsTab; state.pageLabelCollapsed = next.pageLabelCollapsed; @@ -877,6 +975,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes resetStateForSignedOut(); await clearStoredMessages().catch(() => {}); authService.close(); + authService.clearActiveSessionContext(); if (infoMessage) { state.startHint = infoMessage; } diff --git a/shine-UI/js/vendor/solana-web3-loader.js b/shine-UI/js/vendor/solana-web3-loader.js new file mode 100644 index 0000000..2a2d6fe --- /dev/null +++ b/shine-UI/js/vendor/solana-web3-loader.js @@ -0,0 +1,45 @@ +const SOLANA_WEB3_SCRIPT_SRC = './solana-web3.iife.min.js'; + +let solanaWeb3Promise = null; + +function getExistingScript() { + return document.querySelector('script[data-shine-solana-web3="1"]'); +} + +function resolveScriptUrl() { + return new URL(SOLANA_WEB3_SCRIPT_SRC, import.meta.url).toString(); +} + +export function loadSolanaWeb3() { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return Promise.reject(new Error('Solana Web3 loader доступен только в браузере')); + } + if (window.solanaWeb3) { + return Promise.resolve(window.solanaWeb3); + } + if (!solanaWeb3Promise) { + solanaWeb3Promise = new Promise((resolve, reject) => { + const existing = getExistingScript(); + if (existing) { + existing.addEventListener('load', () => resolve(window.solanaWeb3), { once: true }); + existing.addEventListener('error', () => reject(new Error('Не удалось загрузить локальный solana-web3.js')), { once: true }); + return; + } + + const script = document.createElement('script'); + script.src = resolveScriptUrl(); + script.async = true; + script.dataset.shineSolanaWeb3 = '1'; + script.onload = () => { + if (!window.solanaWeb3) { + reject(new Error('Локальный solana-web3.js загружен, но объект solanaWeb3 недоступен')); + return; + } + resolve(window.solanaWeb3); + }; + script.onerror = () => reject(new Error('Не удалось загрузить локальный solana-web3.js')); + document.head.appendChild(script); + }); + } + return solanaWeb3Promise; +} diff --git a/shine-UI/js/vendor/solana-web3.iife.min.js b/shine-UI/js/vendor/solana-web3.iife.min.js new file mode 100644 index 0000000..4531465 --- /dev/null +++ b/shine-UI/js/vendor/solana-web3.iife.min.js @@ -0,0 +1,20 @@ +var solanaWeb3=function(exports){"use strict";function getDefaultExportFromCjs(x){return x&&x.__esModule&&Object.prototype.hasOwnProperty.call(x,"default")?x["default"]:x}function getAugmentedNamespace(n){if(n.__esModule)return n;var f=n.default;if(typeof f=="function"){var a=function a(){if(this instanceof a){return Reflect.construct(f,arguments,this.constructor)}return f.apply(this,arguments)};a.prototype=f.prototype}else a={};Object.defineProperty(a,"__esModule",{value:true});Object.keys(n).forEach((function(k){var d=Object.getOwnPropertyDescriptor(n,k);Object.defineProperty(a,k,d.get?d:{enumerable:true,get:function(){return n[k]}})}));return a}var buffer={};var base64Js={};var hasRequiredBase64Js;function requireBase64Js(){if(hasRequiredBase64Js)return base64Js;hasRequiredBase64Js=1;base64Js.byteLength=byteLength;base64Js.toByteArray=toByteArray;base64Js.fromByteArray=fromByteArray;var lookup=[];var revLookup=[];var Arr=typeof Uint8Array!=="undefined"?Uint8Array:Array;var code="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for(var i=0,len=code.length;i0){throw new Error("Invalid string. Length must be a multiple of 4")}var validLen=b64.indexOf("=");if(validLen===-1)validLen=len;var placeHoldersLen=validLen===len?0:4-validLen%4;return[validLen,placeHoldersLen]}function byteLength(b64){var lens=getLens(b64);var validLen=lens[0];var placeHoldersLen=lens[1];return(validLen+placeHoldersLen)*3/4-placeHoldersLen}function _byteLength(b64,validLen,placeHoldersLen){return(validLen+placeHoldersLen)*3/4-placeHoldersLen}function toByteArray(b64){var tmp;var lens=getLens(b64);var validLen=lens[0];var placeHoldersLen=lens[1];var arr=new Arr(_byteLength(b64,validLen,placeHoldersLen));var curByte=0;var len=placeHoldersLen>0?validLen-4:validLen;var i;for(i=0;i>16&255;arr[curByte++]=tmp>>8&255;arr[curByte++]=tmp&255}if(placeHoldersLen===2){tmp=revLookup[b64.charCodeAt(i)]<<2|revLookup[b64.charCodeAt(i+1)]>>4;arr[curByte++]=tmp&255}if(placeHoldersLen===1){tmp=revLookup[b64.charCodeAt(i)]<<10|revLookup[b64.charCodeAt(i+1)]<<4|revLookup[b64.charCodeAt(i+2)]>>2;arr[curByte++]=tmp>>8&255;arr[curByte++]=tmp&255}return arr}function tripletToBase64(num){return lookup[num>>18&63]+lookup[num>>12&63]+lookup[num>>6&63]+lookup[num&63]}function encodeChunk(uint8,start,end){var tmp;var output=[];for(var i=start;ilen2?len2:i+maxChunkLength))}if(extraBytes===1){tmp=uint8[len-1];parts.push(lookup[tmp>>2]+lookup[tmp<<4&63]+"==")}else if(extraBytes===2){tmp=(uint8[len-2]<<8)+uint8[len-1];parts.push(lookup[tmp>>10]+lookup[tmp>>4&63]+lookup[tmp<<2&63]+"=")}return parts.join("")}return base64Js}var ieee754={}; +/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh */var hasRequiredIeee754;function requireIeee754(){if(hasRequiredIeee754)return ieee754;hasRequiredIeee754=1;ieee754.read=function(buffer,offset,isLE,mLen,nBytes){var e,m;var eLen=nBytes*8-mLen-1;var eMax=(1<>1;var nBits=-7;var i=isLE?nBytes-1:0;var d=isLE?-1:1;var s=buffer[offset+i];i+=d;e=s&(1<<-nBits)-1;s>>=-nBits;nBits+=eLen;for(;nBits>0;e=e*256+buffer[offset+i],i+=d,nBits-=8){}m=e&(1<<-nBits)-1;e>>=-nBits;nBits+=mLen;for(;nBits>0;m=m*256+buffer[offset+i],i+=d,nBits-=8){}if(e===0){e=1-eBias}else if(e===eMax){return m?NaN:(s?-1:1)*Infinity}else{m=m+Math.pow(2,mLen);e=e-eBias}return(s?-1:1)*m*Math.pow(2,e-mLen)};ieee754.write=function(buffer,value,offset,isLE,mLen,nBytes){var e,m,c;var eLen=nBytes*8-mLen-1;var eMax=(1<>1;var rt=mLen===23?Math.pow(2,-24)-Math.pow(2,-77):0;var i=isLE?0:nBytes-1;var d=isLE?1:-1;var s=value<0||value===0&&1/value<0?1:0;value=Math.abs(value);if(isNaN(value)||value===Infinity){m=isNaN(value)?1:0;e=eMax}else{e=Math.floor(Math.log(value)/Math.LN2);if(value*(c=Math.pow(2,-e))<1){e--;c*=2}if(e+eBias>=1){value+=rt/c}else{value+=rt*Math.pow(2,1-eBias)}if(value*c>=2){e++;c/=2}if(e+eBias>=eMax){m=0;e=eMax}else if(e+eBias>=1){m=(value*c-1)*Math.pow(2,mLen);e=e+eBias}else{m=value*Math.pow(2,eBias-1)*Math.pow(2,mLen);e=0}}for(;mLen>=8;buffer[offset+i]=m&255,i+=d,m/=256,mLen-=8){}e=e<0;buffer[offset+i]=e&255,i+=d,e/=256,eLen-=8){}buffer[offset+i-d]|=s*128};return ieee754} +/*! + * The buffer module from node.js, for the browser. + * + * @author Feross Aboukhadijeh + * @license MIT + */var hasRequiredBuffer;function requireBuffer(){if(hasRequiredBuffer)return buffer;hasRequiredBuffer=1;(function(exports){const base64=requireBase64Js();const ieee754=requireIeee754();const customInspectSymbol=typeof Symbol==="function"&&typeof Symbol["for"]==="function"?Symbol["for"]("nodejs.util.inspect.custom"):null;exports.Buffer=Buffer;exports.SlowBuffer=SlowBuffer;exports.INSPECT_MAX_BYTES=50;const K_MAX_LENGTH=2147483647;exports.kMaxLength=K_MAX_LENGTH;Buffer.TYPED_ARRAY_SUPPORT=typedArraySupport();if(!Buffer.TYPED_ARRAY_SUPPORT&&typeof console!=="undefined"&&typeof console.error==="function"){console.error("This browser lacks typed array (Uint8Array) support which is required by "+"`buffer` v5.x. Use `buffer` v4.x if you require old browser support.")}function typedArraySupport(){try{const arr=new Uint8Array(1);const proto={foo:function(){return 42}};Object.setPrototypeOf(proto,Uint8Array.prototype);Object.setPrototypeOf(arr,proto);return arr.foo()===42}catch(e){return false}}Object.defineProperty(Buffer.prototype,"parent",{enumerable:true,get:function(){if(!Buffer.isBuffer(this))return undefined;return this.buffer}});Object.defineProperty(Buffer.prototype,"offset",{enumerable:true,get:function(){if(!Buffer.isBuffer(this))return undefined;return this.byteOffset}});function createBuffer(length){if(length>K_MAX_LENGTH){throw new RangeError('The value "'+length+'" is invalid for option "size"')}const buf=new Uint8Array(length);Object.setPrototypeOf(buf,Buffer.prototype);return buf}function Buffer(arg,encodingOrOffset,length){if(typeof arg==="number"){if(typeof encodingOrOffset==="string"){throw new TypeError('The "string" argument must be of type string. Received type number')}return allocUnsafe(arg)}return from(arg,encodingOrOffset,length)}Buffer.poolSize=8192;function from(value,encodingOrOffset,length){if(typeof value==="string"){return fromString(value,encodingOrOffset)}if(ArrayBuffer.isView(value)){return fromArrayView(value)}if(value==null){throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, "+"or Array-like Object. Received type "+typeof value)}if(isInstance(value,ArrayBuffer)||value&&isInstance(value.buffer,ArrayBuffer)){return fromArrayBuffer(value,encodingOrOffset,length)}if(typeof SharedArrayBuffer!=="undefined"&&(isInstance(value,SharedArrayBuffer)||value&&isInstance(value.buffer,SharedArrayBuffer))){return fromArrayBuffer(value,encodingOrOffset,length)}if(typeof value==="number"){throw new TypeError('The "value" argument must not be of type number. Received type number')}const valueOf=value.valueOf&&value.valueOf();if(valueOf!=null&&valueOf!==value){return Buffer.from(valueOf,encodingOrOffset,length)}const b=fromObject(value);if(b)return b;if(typeof Symbol!=="undefined"&&Symbol.toPrimitive!=null&&typeof value[Symbol.toPrimitive]==="function"){return Buffer.from(value[Symbol.toPrimitive]("string"),encodingOrOffset,length)}throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, "+"or Array-like Object. Received type "+typeof value)}Buffer.from=function(value,encodingOrOffset,length){return from(value,encodingOrOffset,length)};Object.setPrototypeOf(Buffer.prototype,Uint8Array.prototype);Object.setPrototypeOf(Buffer,Uint8Array);function assertSize(size){if(typeof size!=="number"){throw new TypeError('"size" argument must be of type number')}else if(size<0){throw new RangeError('The value "'+size+'" is invalid for option "size"')}}function alloc(size,fill,encoding){assertSize(size);if(size<=0){return createBuffer(size)}if(fill!==undefined){return typeof encoding==="string"?createBuffer(size).fill(fill,encoding):createBuffer(size).fill(fill)}return createBuffer(size)}Buffer.alloc=function(size,fill,encoding){return alloc(size,fill,encoding)};function allocUnsafe(size){assertSize(size);return createBuffer(size<0?0:checked(size)|0)}Buffer.allocUnsafe=function(size){return allocUnsafe(size)};Buffer.allocUnsafeSlow=function(size){return allocUnsafe(size)};function fromString(string,encoding){if(typeof encoding!=="string"||encoding===""){encoding="utf8"}if(!Buffer.isEncoding(encoding)){throw new TypeError("Unknown encoding: "+encoding)}const length=byteLength(string,encoding)|0;let buf=createBuffer(length);const actual=buf.write(string,encoding);if(actual!==length){buf=buf.slice(0,actual)}return buf}function fromArrayLike(array){const length=array.length<0?0:checked(array.length)|0;const buf=createBuffer(length);for(let i=0;i=K_MAX_LENGTH){throw new RangeError("Attempt to allocate Buffer larger than maximum "+"size: 0x"+K_MAX_LENGTH.toString(16)+" bytes")}return length|0}function SlowBuffer(length){if(+length!=length){length=0}return Buffer.alloc(+length)}Buffer.isBuffer=function isBuffer(b){return b!=null&&b._isBuffer===true&&b!==Buffer.prototype};Buffer.compare=function compare(a,b){if(isInstance(a,Uint8Array))a=Buffer.from(a,a.offset,a.byteLength);if(isInstance(b,Uint8Array))b=Buffer.from(b,b.offset,b.byteLength);if(!Buffer.isBuffer(a)||!Buffer.isBuffer(b)){throw new TypeError('The "buf1", "buf2" arguments must be one of type Buffer or Uint8Array')}if(a===b)return 0;let x=a.length;let y=b.length;for(let i=0,len=Math.min(x,y);ibuffer.length){if(!Buffer.isBuffer(buf))buf=Buffer.from(buf);buf.copy(buffer,pos)}else{Uint8Array.prototype.set.call(buffer,buf,pos)}}else if(!Buffer.isBuffer(buf)){throw new TypeError('"list" argument must be an Array of Buffers')}else{buf.copy(buffer,pos)}pos+=buf.length}return buffer};function byteLength(string,encoding){if(Buffer.isBuffer(string)){return string.length}if(ArrayBuffer.isView(string)||isInstance(string,ArrayBuffer)){return string.byteLength}if(typeof string!=="string"){throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. '+"Received type "+typeof string)}const len=string.length;const mustMatch=arguments.length>2&&arguments[2]===true;if(!mustMatch&&len===0)return 0;let loweredCase=false;for(;;){switch(encoding){case"ascii":case"latin1":case"binary":return len;case"utf8":case"utf-8":return utf8ToBytes(string).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return len*2;case"hex":return len>>>1;case"base64":return base64ToBytes(string).length;default:if(loweredCase){return mustMatch?-1:utf8ToBytes(string).length}encoding=(""+encoding).toLowerCase();loweredCase=true}}}Buffer.byteLength=byteLength;function slowToString(encoding,start,end){let loweredCase=false;if(start===undefined||start<0){start=0}if(start>this.length){return""}if(end===undefined||end>this.length){end=this.length}if(end<=0){return""}end>>>=0;start>>>=0;if(end<=start){return""}if(!encoding)encoding="utf8";while(true){switch(encoding){case"hex":return hexSlice(this,start,end);case"utf8":case"utf-8":return utf8Slice(this,start,end);case"ascii":return asciiSlice(this,start,end);case"latin1":case"binary":return latin1Slice(this,start,end);case"base64":return base64Slice(this,start,end);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,start,end);default:if(loweredCase)throw new TypeError("Unknown encoding: "+encoding);encoding=(encoding+"").toLowerCase();loweredCase=true}}}Buffer.prototype._isBuffer=true;function swap(b,n,m){const i=b[n];b[n]=b[m];b[m]=i}Buffer.prototype.swap16=function swap16(){const len=this.length;if(len%2!==0){throw new RangeError("Buffer size must be a multiple of 16-bits")}for(let i=0;imax)str+=" ... ";return""};if(customInspectSymbol){Buffer.prototype[customInspectSymbol]=Buffer.prototype.inspect}Buffer.prototype.compare=function compare(target,start,end,thisStart,thisEnd){if(isInstance(target,Uint8Array)){target=Buffer.from(target,target.offset,target.byteLength)}if(!Buffer.isBuffer(target)){throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. '+"Received type "+typeof target)}if(start===undefined){start=0}if(end===undefined){end=target?target.length:0}if(thisStart===undefined){thisStart=0}if(thisEnd===undefined){thisEnd=this.length}if(start<0||end>target.length||thisStart<0||thisEnd>this.length){throw new RangeError("out of range index")}if(thisStart>=thisEnd&&start>=end){return 0}if(thisStart>=thisEnd){return-1}if(start>=end){return 1}start>>>=0;end>>>=0;thisStart>>>=0;thisEnd>>>=0;if(this===target)return 0;let x=thisEnd-thisStart;let y=end-start;const len=Math.min(x,y);const thisCopy=this.slice(thisStart,thisEnd);const targetCopy=target.slice(start,end);for(let i=0;i2147483647){byteOffset=2147483647}else if(byteOffset<-2147483648){byteOffset=-2147483648}byteOffset=+byteOffset;if(numberIsNaN(byteOffset)){byteOffset=dir?0:buffer.length-1}if(byteOffset<0)byteOffset=buffer.length+byteOffset;if(byteOffset>=buffer.length){if(dir)return-1;else byteOffset=buffer.length-1}else if(byteOffset<0){if(dir)byteOffset=0;else return-1}if(typeof val==="string"){val=Buffer.from(val,encoding)}if(Buffer.isBuffer(val)){if(val.length===0){return-1}return arrayIndexOf(buffer,val,byteOffset,encoding,dir)}else if(typeof val==="number"){val=val&255;if(typeof Uint8Array.prototype.indexOf==="function"){if(dir){return Uint8Array.prototype.indexOf.call(buffer,val,byteOffset)}else{return Uint8Array.prototype.lastIndexOf.call(buffer,val,byteOffset)}}return arrayIndexOf(buffer,[val],byteOffset,encoding,dir)}throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(arr,val,byteOffset,encoding,dir){let indexSize=1;let arrLength=arr.length;let valLength=val.length;if(encoding!==undefined){encoding=String(encoding).toLowerCase();if(encoding==="ucs2"||encoding==="ucs-2"||encoding==="utf16le"||encoding==="utf-16le"){if(arr.length<2||val.length<2){return-1}indexSize=2;arrLength/=2;valLength/=2;byteOffset/=2}}function read(buf,i){if(indexSize===1){return buf[i]}else{return buf.readUInt16BE(i*indexSize)}}let i;if(dir){let foundIndex=-1;for(i=byteOffset;iarrLength)byteOffset=arrLength-valLength;for(i=byteOffset;i>=0;i--){let found=true;for(let j=0;jremaining){length=remaining}}const strLen=string.length;if(length>strLen/2){length=strLen/2}let i;for(i=0;i>>0;if(isFinite(length)){length=length>>>0;if(encoding===undefined)encoding="utf8"}else{encoding=length;length=undefined}}else{throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported")}const remaining=this.length-offset;if(length===undefined||length>remaining)length=remaining;if(string.length>0&&(length<0||offset<0)||offset>this.length){throw new RangeError("Attempt to write outside buffer bounds")}if(!encoding)encoding="utf8";let loweredCase=false;for(;;){switch(encoding){case"hex":return hexWrite(this,string,offset,length);case"utf8":case"utf-8":return utf8Write(this,string,offset,length);case"ascii":case"latin1":case"binary":return asciiWrite(this,string,offset,length);case"base64":return base64Write(this,string,offset,length);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,string,offset,length);default:if(loweredCase)throw new TypeError("Unknown encoding: "+encoding);encoding=(""+encoding).toLowerCase();loweredCase=true}}};Buffer.prototype.toJSON=function toJSON(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};function base64Slice(buf,start,end){if(start===0&&end===buf.length){return base64.fromByteArray(buf)}else{return base64.fromByteArray(buf.slice(start,end))}}function utf8Slice(buf,start,end){end=Math.min(buf.length,end);const res=[];let i=start;while(i239?4:firstByte>223?3:firstByte>191?2:1;if(i+bytesPerSequence<=end){let secondByte,thirdByte,fourthByte,tempCodePoint;switch(bytesPerSequence){case 1:if(firstByte<128){codePoint=firstByte}break;case 2:secondByte=buf[i+1];if((secondByte&192)===128){tempCodePoint=(firstByte&31)<<6|secondByte&63;if(tempCodePoint>127){codePoint=tempCodePoint}}break;case 3:secondByte=buf[i+1];thirdByte=buf[i+2];if((secondByte&192)===128&&(thirdByte&192)===128){tempCodePoint=(firstByte&15)<<12|(secondByte&63)<<6|thirdByte&63;if(tempCodePoint>2047&&(tempCodePoint<55296||tempCodePoint>57343)){codePoint=tempCodePoint}}break;case 4:secondByte=buf[i+1];thirdByte=buf[i+2];fourthByte=buf[i+3];if((secondByte&192)===128&&(thirdByte&192)===128&&(fourthByte&192)===128){tempCodePoint=(firstByte&15)<<18|(secondByte&63)<<12|(thirdByte&63)<<6|fourthByte&63;if(tempCodePoint>65535&&tempCodePoint<1114112){codePoint=tempCodePoint}}}}if(codePoint===null){codePoint=65533;bytesPerSequence=1}else if(codePoint>65535){codePoint-=65536;res.push(codePoint>>>10&1023|55296);codePoint=56320|codePoint&1023}res.push(codePoint);i+=bytesPerSequence}return decodeCodePointsArray(res)}const MAX_ARGUMENTS_LENGTH=4096;function decodeCodePointsArray(codePoints){const len=codePoints.length;if(len<=MAX_ARGUMENTS_LENGTH){return String.fromCharCode.apply(String,codePoints)}let res="";let i=0;while(ilen)end=len;let out="";for(let i=start;ilen){start=len}if(end<0){end+=len;if(end<0)end=0}else if(end>len){end=len}if(endlength)throw new RangeError("Trying to access beyond buffer length")}Buffer.prototype.readUintLE=Buffer.prototype.readUIntLE=function readUIntLE(offset,byteLength,noAssert){offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);let val=this[offset];let mul=1;let i=0;while(++i>>0;byteLength=byteLength>>>0;if(!noAssert){checkOffset(offset,byteLength,this.length)}let val=this[offset+--byteLength];let mul=1;while(byteLength>0&&(mul*=256)){val+=this[offset+--byteLength]*mul}return val};Buffer.prototype.readUint8=Buffer.prototype.readUInt8=function readUInt8(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,1,this.length);return this[offset]};Buffer.prototype.readUint16LE=Buffer.prototype.readUInt16LE=function readUInt16LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);return this[offset]|this[offset+1]<<8};Buffer.prototype.readUint16BE=Buffer.prototype.readUInt16BE=function readUInt16BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);return this[offset]<<8|this[offset+1]};Buffer.prototype.readUint32LE=Buffer.prototype.readUInt32LE=function readUInt32LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return(this[offset]|this[offset+1]<<8|this[offset+2]<<16)+this[offset+3]*16777216};Buffer.prototype.readUint32BE=Buffer.prototype.readUInt32BE=function readUInt32BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]*16777216+(this[offset+1]<<16|this[offset+2]<<8|this[offset+3])};Buffer.prototype.readBigUInt64LE=defineBigIntMethod((function readBigUInt64LE(offset){offset=offset>>>0;validateNumber(offset,"offset");const first=this[offset];const last=this[offset+7];if(first===undefined||last===undefined){boundsError(offset,this.length-8)}const lo=first+this[++offset]*2**8+this[++offset]*2**16+this[++offset]*2**24;const hi=this[++offset]+this[++offset]*2**8+this[++offset]*2**16+last*2**24;return BigInt(lo)+(BigInt(hi)<>>0;validateNumber(offset,"offset");const first=this[offset];const last=this[offset+7];if(first===undefined||last===undefined){boundsError(offset,this.length-8)}const hi=first*2**24+this[++offset]*2**16+this[++offset]*2**8+this[++offset];const lo=this[++offset]*2**24+this[++offset]*2**16+this[++offset]*2**8+last;return(BigInt(hi)<>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);let val=this[offset];let mul=1;let i=0;while(++i=mul)val-=Math.pow(2,8*byteLength);return val};Buffer.prototype.readIntBE=function readIntBE(offset,byteLength,noAssert){offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);let i=byteLength;let mul=1;let val=this[offset+--i];while(i>0&&(mul*=256)){val+=this[offset+--i]*mul}mul*=128;if(val>=mul)val-=Math.pow(2,8*byteLength);return val};Buffer.prototype.readInt8=function readInt8(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,1,this.length);if(!(this[offset]&128))return this[offset];return(255-this[offset]+1)*-1};Buffer.prototype.readInt16LE=function readInt16LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);const val=this[offset]|this[offset+1]<<8;return val&32768?val|4294901760:val};Buffer.prototype.readInt16BE=function readInt16BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);const val=this[offset+1]|this[offset]<<8;return val&32768?val|4294901760:val};Buffer.prototype.readInt32LE=function readInt32LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]|this[offset+1]<<8|this[offset+2]<<16|this[offset+3]<<24};Buffer.prototype.readInt32BE=function readInt32BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]<<24|this[offset+1]<<16|this[offset+2]<<8|this[offset+3]};Buffer.prototype.readBigInt64LE=defineBigIntMethod((function readBigInt64LE(offset){offset=offset>>>0;validateNumber(offset,"offset");const first=this[offset];const last=this[offset+7];if(first===undefined||last===undefined){boundsError(offset,this.length-8)}const val=this[offset+4]+this[offset+5]*2**8+this[offset+6]*2**16+(last<<24);return(BigInt(val)<>>0;validateNumber(offset,"offset");const first=this[offset];const last=this[offset+7];if(first===undefined||last===undefined){boundsError(offset,this.length-8)}const val=(first<<24)+this[++offset]*2**16+this[++offset]*2**8+this[++offset];return(BigInt(val)<>>0;if(!noAssert)checkOffset(offset,4,this.length);return ieee754.read(this,offset,true,23,4)};Buffer.prototype.readFloatBE=function readFloatBE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return ieee754.read(this,offset,false,23,4)};Buffer.prototype.readDoubleLE=function readDoubleLE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,8,this.length);return ieee754.read(this,offset,true,52,8)};Buffer.prototype.readDoubleBE=function readDoubleBE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,8,this.length);return ieee754.read(this,offset,false,52,8)};function checkInt(buf,value,offset,ext,max,min){if(!Buffer.isBuffer(buf))throw new TypeError('"buffer" argument must be a Buffer instance');if(value>max||valuebuf.length)throw new RangeError("Index out of range")}Buffer.prototype.writeUintLE=Buffer.prototype.writeUIntLE=function writeUIntLE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert){const maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}let mul=1;let i=0;this[offset]=value&255;while(++i>>0;byteLength=byteLength>>>0;if(!noAssert){const maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}let i=byteLength-1;let mul=1;this[offset+i]=value&255;while(--i>=0&&(mul*=256)){this[offset+i]=value/mul&255}return offset+byteLength};Buffer.prototype.writeUint8=Buffer.prototype.writeUInt8=function writeUInt8(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,1,255,0);this[offset]=value&255;return offset+1};Buffer.prototype.writeUint16LE=Buffer.prototype.writeUInt16LE=function writeUInt16LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,65535,0);this[offset]=value&255;this[offset+1]=value>>>8;return offset+2};Buffer.prototype.writeUint16BE=Buffer.prototype.writeUInt16BE=function writeUInt16BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,65535,0);this[offset]=value>>>8;this[offset+1]=value&255;return offset+2};Buffer.prototype.writeUint32LE=Buffer.prototype.writeUInt32LE=function writeUInt32LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,4294967295,0);this[offset+3]=value>>>24;this[offset+2]=value>>>16;this[offset+1]=value>>>8;this[offset]=value&255;return offset+4};Buffer.prototype.writeUint32BE=Buffer.prototype.writeUInt32BE=function writeUInt32BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,4294967295,0);this[offset]=value>>>24;this[offset+1]=value>>>16;this[offset+2]=value>>>8;this[offset+3]=value&255;return offset+4};function wrtBigUInt64LE(buf,value,offset,min,max){checkIntBI(value,min,max,buf,offset,7);let lo=Number(value&BigInt(4294967295));buf[offset++]=lo;lo=lo>>8;buf[offset++]=lo;lo=lo>>8;buf[offset++]=lo;lo=lo>>8;buf[offset++]=lo;let hi=Number(value>>BigInt(32)&BigInt(4294967295));buf[offset++]=hi;hi=hi>>8;buf[offset++]=hi;hi=hi>>8;buf[offset++]=hi;hi=hi>>8;buf[offset++]=hi;return offset}function wrtBigUInt64BE(buf,value,offset,min,max){checkIntBI(value,min,max,buf,offset,7);let lo=Number(value&BigInt(4294967295));buf[offset+7]=lo;lo=lo>>8;buf[offset+6]=lo;lo=lo>>8;buf[offset+5]=lo;lo=lo>>8;buf[offset+4]=lo;let hi=Number(value>>BigInt(32)&BigInt(4294967295));buf[offset+3]=hi;hi=hi>>8;buf[offset+2]=hi;hi=hi>>8;buf[offset+1]=hi;hi=hi>>8;buf[offset]=hi;return offset+8}Buffer.prototype.writeBigUInt64LE=defineBigIntMethod((function writeBigUInt64LE(value,offset=0){return wrtBigUInt64LE(this,value,offset,BigInt(0),BigInt("0xffffffffffffffff"))}));Buffer.prototype.writeBigUInt64BE=defineBigIntMethod((function writeBigUInt64BE(value,offset=0){return wrtBigUInt64BE(this,value,offset,BigInt(0),BigInt("0xffffffffffffffff"))}));Buffer.prototype.writeIntLE=function writeIntLE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;if(!noAssert){const limit=Math.pow(2,8*byteLength-1);checkInt(this,value,offset,byteLength,limit-1,-limit)}let i=0;let mul=1;let sub=0;this[offset]=value&255;while(++i>0)-sub&255}return offset+byteLength};Buffer.prototype.writeIntBE=function writeIntBE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;if(!noAssert){const limit=Math.pow(2,8*byteLength-1);checkInt(this,value,offset,byteLength,limit-1,-limit)}let i=byteLength-1;let mul=1;let sub=0;this[offset+i]=value&255;while(--i>=0&&(mul*=256)){if(value<0&&sub===0&&this[offset+i+1]!==0){sub=1}this[offset+i]=(value/mul>>0)-sub&255}return offset+byteLength};Buffer.prototype.writeInt8=function writeInt8(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,1,127,-128);if(value<0)value=255+value+1;this[offset]=value&255;return offset+1};Buffer.prototype.writeInt16LE=function writeInt16LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,32767,-32768);this[offset]=value&255;this[offset+1]=value>>>8;return offset+2};Buffer.prototype.writeInt16BE=function writeInt16BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,32767,-32768);this[offset]=value>>>8;this[offset+1]=value&255;return offset+2};Buffer.prototype.writeInt32LE=function writeInt32LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,2147483647,-2147483648);this[offset]=value&255;this[offset+1]=value>>>8;this[offset+2]=value>>>16;this[offset+3]=value>>>24;return offset+4};Buffer.prototype.writeInt32BE=function writeInt32BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,2147483647,-2147483648);if(value<0)value=4294967295+value+1;this[offset]=value>>>24;this[offset+1]=value>>>16;this[offset+2]=value>>>8;this[offset+3]=value&255;return offset+4};Buffer.prototype.writeBigInt64LE=defineBigIntMethod((function writeBigInt64LE(value,offset=0){return wrtBigUInt64LE(this,value,offset,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))}));Buffer.prototype.writeBigInt64BE=defineBigIntMethod((function writeBigInt64BE(value,offset=0){return wrtBigUInt64BE(this,value,offset,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))}));function checkIEEE754(buf,value,offset,ext,max,min){if(offset+ext>buf.length)throw new RangeError("Index out of range");if(offset<0)throw new RangeError("Index out of range")}function writeFloat(buf,value,offset,littleEndian,noAssert){value=+value;offset=offset>>>0;if(!noAssert){checkIEEE754(buf,value,offset,4)}ieee754.write(buf,value,offset,littleEndian,23,4);return offset+4}Buffer.prototype.writeFloatLE=function writeFloatLE(value,offset,noAssert){return writeFloat(this,value,offset,true,noAssert)};Buffer.prototype.writeFloatBE=function writeFloatBE(value,offset,noAssert){return writeFloat(this,value,offset,false,noAssert)};function writeDouble(buf,value,offset,littleEndian,noAssert){value=+value;offset=offset>>>0;if(!noAssert){checkIEEE754(buf,value,offset,8)}ieee754.write(buf,value,offset,littleEndian,52,8);return offset+8}Buffer.prototype.writeDoubleLE=function writeDoubleLE(value,offset,noAssert){return writeDouble(this,value,offset,true,noAssert)};Buffer.prototype.writeDoubleBE=function writeDoubleBE(value,offset,noAssert){return writeDouble(this,value,offset,false,noAssert)};Buffer.prototype.copy=function copy(target,targetStart,start,end){if(!Buffer.isBuffer(target))throw new TypeError("argument should be a Buffer");if(!start)start=0;if(!end&&end!==0)end=this.length;if(targetStart>=target.length)targetStart=target.length;if(!targetStart)targetStart=0;if(end>0&&end=this.length)throw new RangeError("Index out of range");if(end<0)throw new RangeError("sourceEnd out of bounds");if(end>this.length)end=this.length;if(target.length-targetStart>>0;end=end===undefined?this.length:end>>>0;if(!val)val=0;let i;if(typeof val==="number"){for(i=start;i2**32){received=addNumericalSeparator(String(input))}else if(typeof input==="bigint"){received=String(input);if(input>BigInt(2)**BigInt(32)||input<-(BigInt(2)**BigInt(32))){received=addNumericalSeparator(received)}received+="n"}msg+=` It must be ${range}. Received ${received}`;return msg}),RangeError);function addNumericalSeparator(val){let res="";let i=val.length;const start=val[0]==="-"?1:0;for(;i>=start+4;i-=3){res=`_${val.slice(i-3,i)}${res}`}return`${val.slice(0,i)}${res}`}function checkBounds(buf,offset,byteLength){validateNumber(offset,"offset");if(buf[offset]===undefined||buf[offset+byteLength]===undefined){boundsError(offset,buf.length-(byteLength+1))}}function checkIntBI(value,min,max,buf,offset,byteLength){if(value>max||value= 0${n} and < 2${n} ** ${(byteLength+1)*8}${n}`}else{range=`>= -(2${n} ** ${(byteLength+1)*8-1}${n}) and < 2 ** `+`${(byteLength+1)*8-1}${n}`}}throw new errors.ERR_OUT_OF_RANGE("value",range,value)}checkBounds(buf,offset,byteLength)}function validateNumber(value,name){if(typeof value!=="number"){throw new errors.ERR_INVALID_ARG_TYPE(name,"number",value)}}function boundsError(value,length,type){if(Math.floor(value)!==value){validateNumber(value,type);throw new errors.ERR_OUT_OF_RANGE("offset","an integer",value)}if(length<0){throw new errors.ERR_BUFFER_OUT_OF_BOUNDS}throw new errors.ERR_OUT_OF_RANGE("offset",`>= ${0} and <= ${length}`,value)}const INVALID_BASE64_RE=/[^+/0-9A-Za-z-_]/g;function base64clean(str){str=str.split("=")[0];str=str.trim().replace(INVALID_BASE64_RE,"");if(str.length<2)return"";while(str.length%4!==0){str=str+"="}return str}function utf8ToBytes(string,units){units=units||Infinity;let codePoint;const length=string.length;let leadSurrogate=null;const bytes=[];for(let i=0;i55295&&codePoint<57344){if(!leadSurrogate){if(codePoint>56319){if((units-=3)>-1)bytes.push(239,191,189);continue}else if(i+1===length){if((units-=3)>-1)bytes.push(239,191,189);continue}leadSurrogate=codePoint;continue}if(codePoint<56320){if((units-=3)>-1)bytes.push(239,191,189);leadSurrogate=codePoint;continue}codePoint=(leadSurrogate-55296<<10|codePoint-56320)+65536}else if(leadSurrogate){if((units-=3)>-1)bytes.push(239,191,189)}leadSurrogate=null;if(codePoint<128){if((units-=1)<0)break;bytes.push(codePoint)}else if(codePoint<2048){if((units-=2)<0)break;bytes.push(codePoint>>6|192,codePoint&63|128)}else if(codePoint<65536){if((units-=3)<0)break;bytes.push(codePoint>>12|224,codePoint>>6&63|128,codePoint&63|128)}else if(codePoint<1114112){if((units-=4)<0)break;bytes.push(codePoint>>18|240,codePoint>>12&63|128,codePoint>>6&63|128,codePoint&63|128)}else{throw new Error("Invalid code point")}}return bytes}function asciiToBytes(str){const byteArray=[];for(let i=0;i>8;lo=c%256;byteArray.push(lo);byteArray.push(hi)}return byteArray}function base64ToBytes(str){return base64.toByteArray(base64clean(str))}function blitBuffer(src,dst,offset,length){let i;for(i=0;i=dst.length||i>=src.length)break;dst[i+offset]=src[i]}return i}function isInstance(obj,type){return obj instanceof type||obj!=null&&obj.constructor!=null&&obj.constructor.name!=null&&obj.constructor.name===type.name}function numberIsNaN(obj){return obj!==obj}const hexSliceLookupTable=function(){const alphabet="0123456789abcdef";const table=new Array(256);for(let i=0;i<16;++i){const i16=i*16;for(let j=0;j<16;++j){table[i16+j]=alphabet[i]+alphabet[j]}}return table}();function defineBigIntMethod(fn){return typeof BigInt==="undefined"?BufferBigIntNotDefined:fn}function BufferBigIntNotDefined(){throw new Error("BigInt not supported")}})(buffer);return buffer}var bufferExports=requireBuffer();function anumber$1(n){if(!Number.isSafeInteger(n)||n<0)throw new Error("positive integer expected, got "+n)}function isBytes$2(a){return a instanceof Uint8Array||ArrayBuffer.isView(a)&&a.constructor.name==="Uint8Array"}function abytes$2(b,...lengths){if(!isBytes$2(b))throw new Error("Uint8Array expected");if(lengths.length>0&&!lengths.includes(b.length))throw new Error("Uint8Array expected of length "+lengths+", got length="+b.length)}function ahash(h){if(typeof h!=="function"||typeof h.create!=="function")throw new Error("Hash should be wrapped by utils.wrapConstructor");anumber$1(h.outputLen);anumber$1(h.blockLen)}function aexists$1(instance,checkFinished=true){if(instance.destroyed)throw new Error("Hash instance has been destroyed");if(checkFinished&&instance.finished)throw new Error("Hash#digest() has already been called")}function aoutput$1(out,instance){abytes$2(out);const min=instance.outputLen;if(out.lengthnew DataView(arr.buffer,arr.byteOffset,arr.byteLength);const rotr$1=(word,shift)=>word<<32-shift|word>>>shift;function utf8ToBytes$2(str){if(typeof str!=="string")throw new Error("utf8ToBytes expected string, got "+typeof str);return new Uint8Array((new TextEncoder).encode(str))}function toBytes$1(data){if(typeof data==="string")data=utf8ToBytes$2(data);abytes$2(data);return data}function concatBytes$1(...arrays){let sum=0;for(let i=0;ihashCons().update(toBytes$1(msg)).digest();const tmp=hashCons();hashC.outputLen=tmp.outputLen;hashC.blockLen=tmp.blockLen;hashC.create=()=>hashCons();return hashC}function randomBytes(bytesLength=32){if(crypto$1&&typeof crypto$1.getRandomValues==="function"){return crypto$1.getRandomValues(new Uint8Array(bytesLength))}if(crypto$1&&typeof crypto$1.randomBytes==="function"){return crypto$1.randomBytes(bytesLength)}throw new Error("crypto.getRandomValues must be defined")}function setBigUint64$1(view,byteOffset,value,isLE){if(typeof view.setBigUint64==="function")return view.setBigUint64(byteOffset,value,isLE);const _32n=BigInt(32);const _u32_max=BigInt(4294967295);const wh=Number(value>>_32n&_u32_max);const wl=Number(value&_u32_max);const h=isLE?4:0;const l=isLE?0:4;view.setUint32(byteOffset+h,wh,isLE);view.setUint32(byteOffset+l,wl,isLE)}const Chi$1=(a,b,c)=>a&b^~a&c;const Maj$1=(a,b,c)=>a&b^a&c^b&c;let HashMD$1=class HashMD extends Hash$1{constructor(blockLen,outputLen,padOffset,isLE){super();this.blockLen=blockLen;this.outputLen=outputLen;this.padOffset=padOffset;this.isLE=isLE;this.finished=false;this.length=0;this.pos=0;this.destroyed=false;this.buffer=new Uint8Array(blockLen);this.view=createView$1(this.buffer)}update(data){aexists$1(this);const{view:view,buffer:buffer,blockLen:blockLen}=this;data=toBytes$1(data);const len=data.length;for(let pos=0;posblockLen-pos){this.process(view,0);pos=0}for(let i=pos;istate.length)throw new Error("_sha2: outputLen bigger than state");for(let i=0;i>_32n$1&U32_MASK64$1)};return{h:Number(n>>_32n$1&U32_MASK64$1)|0,l:Number(n&U32_MASK64$1)|0}}function split$1(lst,le=false){let Ah=new Uint32Array(lst.length);let Al=new Uint32Array(lst.length);for(let i=0;iBigInt(h>>>0)<<_32n$1|BigInt(l>>>0);const shrSH=(h,_l,s)=>h>>>s;const shrSL=(h,l,s)=>h<<32-s|l>>>s;const rotrSH=(h,l,s)=>h>>>s|l<<32-s;const rotrSL=(h,l,s)=>h<<32-s|l>>>s;const rotrBH=(h,l,s)=>h<<64-s|l>>>s-32;const rotrBL=(h,l,s)=>h>>>s-32|l<<64-s;const rotr32H=(_h,l)=>l;const rotr32L=(h,_l)=>h;const rotlSH$1=(h,l,s)=>h<>>32-s;const rotlSL$1=(h,l,s)=>l<>>32-s;const rotlBH$1=(h,l,s)=>l<>>64-s;const rotlBL$1=(h,l,s)=>h<>>64-s;function add(Ah,Al,Bh,Bl){const l=(Al>>>0)+(Bl>>>0);return{h:Ah+Bh+(l/2**32|0)|0,l:l|0}}const add3L=(Al,Bl,Cl)=>(Al>>>0)+(Bl>>>0)+(Cl>>>0);const add3H=(low,Ah,Bh,Ch)=>Ah+Bh+Ch+(low/2**32|0)|0;const add4L=(Al,Bl,Cl,Dl)=>(Al>>>0)+(Bl>>>0)+(Cl>>>0)+(Dl>>>0);const add4H=(low,Ah,Bh,Ch,Dh)=>Ah+Bh+Ch+Dh+(low/2**32|0)|0;const add5L=(Al,Bl,Cl,Dl,El)=>(Al>>>0)+(Bl>>>0)+(Cl>>>0)+(Dl>>>0)+(El>>>0);const add5H=(low,Ah,Bh,Ch,Dh,Eh)=>Ah+Bh+Ch+Dh+Eh+(low/2**32|0)|0;const u64$1={fromBig:fromBig$1,split:split$1,toBig:toBig,shrSH:shrSH,shrSL:shrSL,rotrSH:rotrSH,rotrSL:rotrSL,rotrBH:rotrBH,rotrBL:rotrBL,rotr32H:rotr32H,rotr32L:rotr32L,rotlSH:rotlSH$1,rotlSL:rotlSL$1,rotlBH:rotlBH$1,rotlBL:rotlBL$1,add:add,add3L:add3L,add3H:add3H,add4L:add4L,add4H:add4H,add5H:add5H,add5L:add5L};const[SHA512_Kh,SHA512_Kl]=(()=>u64$1.split(["0x428a2f98d728ae22","0x7137449123ef65cd","0xb5c0fbcfec4d3b2f","0xe9b5dba58189dbbc","0x3956c25bf348b538","0x59f111f1b605d019","0x923f82a4af194f9b","0xab1c5ed5da6d8118","0xd807aa98a3030242","0x12835b0145706fbe","0x243185be4ee4b28c","0x550c7dc3d5ffb4e2","0x72be5d74f27b896f","0x80deb1fe3b1696b1","0x9bdc06a725c71235","0xc19bf174cf692694","0xe49b69c19ef14ad2","0xefbe4786384f25e3","0x0fc19dc68b8cd5b5","0x240ca1cc77ac9c65","0x2de92c6f592b0275","0x4a7484aa6ea6e483","0x5cb0a9dcbd41fbd4","0x76f988da831153b5","0x983e5152ee66dfab","0xa831c66d2db43210","0xb00327c898fb213f","0xbf597fc7beef0ee4","0xc6e00bf33da88fc2","0xd5a79147930aa725","0x06ca6351e003826f","0x142929670a0e6e70","0x27b70a8546d22ffc","0x2e1b21385c26c926","0x4d2c6dfc5ac42aed","0x53380d139d95b3df","0x650a73548baf63de","0x766a0abb3c77b2a8","0x81c2c92e47edaee6","0x92722c851482353b","0xa2bfe8a14cf10364","0xa81a664bbc423001","0xc24b8b70d0f89791","0xc76c51a30654be30","0xd192e819d6ef5218","0xd69906245565a910","0xf40e35855771202a","0x106aa07032bbd1b8","0x19a4c116b8d2d0c8","0x1e376c085141ab53","0x2748774cdf8eeb99","0x34b0bcb5e19b48a8","0x391c0cb3c5c95a63","0x4ed8aa4ae3418acb","0x5b9cca4f7763e373","0x682e6ff3d6b2b8a3","0x748f82ee5defb2fc","0x78a5636f43172f60","0x84c87814a1f0ab72","0x8cc702081a6439ec","0x90befffa23631e28","0xa4506cebde82bde9","0xbef9a3f7b2c67915","0xc67178f2e372532b","0xca273eceea26619c","0xd186b8c721c0c207","0xeada7dd6cde0eb1e","0xf57d4f7fee6ed178","0x06f067aa72176fba","0x0a637dc5a2c898a6","0x113f9804bef90dae","0x1b710b35131c471b","0x28db77f523047d84","0x32caab7b40c72493","0x3c9ebe0a15c9bebc","0x431d67c49c100d4c","0x4cc5d4becb3e42b6","0x597f299cfc657e2a","0x5fcb6fab3ad6faec","0x6c44198c4a475817"].map((n=>BigInt(n)))))();const SHA512_W_H=new Uint32Array(80);const SHA512_W_L=new Uint32Array(80);class SHA512 extends HashMD$1{constructor(){super(128,64,16,false);this.Ah=1779033703|0;this.Al=4089235720|0;this.Bh=3144134277|0;this.Bl=2227873595|0;this.Ch=1013904242|0;this.Cl=4271175723|0;this.Dh=2773480762|0;this.Dl=1595750129|0;this.Eh=1359893119|0;this.El=2917565137|0;this.Fh=2600822924|0;this.Fl=725511199|0;this.Gh=528734635|0;this.Gl=4215389547|0;this.Hh=1541459225|0;this.Hl=327033209|0}get(){const{Ah:Ah,Al:Al,Bh:Bh,Bl:Bl,Ch:Ch,Cl:Cl,Dh:Dh,Dl:Dl,Eh:Eh,El:El,Fh:Fh,Fl:Fl,Gh:Gh,Gl:Gl,Hh:Hh,Hl:Hl}=this;return[Ah,Al,Bh,Bl,Ch,Cl,Dh,Dl,Eh,El,Fh,Fl,Gh,Gl,Hh,Hl]}set(Ah,Al,Bh,Bl,Ch,Cl,Dh,Dl,Eh,El,Fh,Fl,Gh,Gl,Hh,Hl){this.Ah=Ah|0;this.Al=Al|0;this.Bh=Bh|0;this.Bl=Bl|0;this.Ch=Ch|0;this.Cl=Cl|0;this.Dh=Dh|0;this.Dl=Dl|0;this.Eh=Eh|0;this.El=El|0;this.Fh=Fh|0;this.Fl=Fl|0;this.Gh=Gh|0;this.Gl=Gl|0;this.Hh=Hh|0;this.Hl=Hl|0}process(view,offset){for(let i=0;i<16;i++,offset+=4){SHA512_W_H[i]=view.getUint32(offset);SHA512_W_L[i]=view.getUint32(offset+=4)}for(let i=16;i<80;i++){const W15h=SHA512_W_H[i-15]|0;const W15l=SHA512_W_L[i-15]|0;const s0h=u64$1.rotrSH(W15h,W15l,1)^u64$1.rotrSH(W15h,W15l,8)^u64$1.shrSH(W15h,W15l,7);const s0l=u64$1.rotrSL(W15h,W15l,1)^u64$1.rotrSL(W15h,W15l,8)^u64$1.shrSL(W15h,W15l,7);const W2h=SHA512_W_H[i-2]|0;const W2l=SHA512_W_L[i-2]|0;const s1h=u64$1.rotrSH(W2h,W2l,19)^u64$1.rotrBH(W2h,W2l,61)^u64$1.shrSH(W2h,W2l,6);const s1l=u64$1.rotrSL(W2h,W2l,19)^u64$1.rotrBL(W2h,W2l,61)^u64$1.shrSL(W2h,W2l,6);const SUMl=u64$1.add4L(s0l,s1l,SHA512_W_L[i-7],SHA512_W_L[i-16]);const SUMh=u64$1.add4H(SUMl,s0h,s1h,SHA512_W_H[i-7],SHA512_W_H[i-16]);SHA512_W_H[i]=SUMh|0;SHA512_W_L[i]=SUMl|0}let{Ah:Ah,Al:Al,Bh:Bh,Bl:Bl,Ch:Ch,Cl:Cl,Dh:Dh,Dl:Dl,Eh:Eh,El:El,Fh:Fh,Fl:Fl,Gh:Gh,Gl:Gl,Hh:Hh,Hl:Hl}=this;for(let i=0;i<80;i++){const sigma1h=u64$1.rotrSH(Eh,El,14)^u64$1.rotrSH(Eh,El,18)^u64$1.rotrBH(Eh,El,41);const sigma1l=u64$1.rotrSL(Eh,El,14)^u64$1.rotrSL(Eh,El,18)^u64$1.rotrBL(Eh,El,41);const CHIh=Eh&Fh^~Eh&Gh;const CHIl=El&Fl^~El&Gl;const T1ll=u64$1.add5L(Hl,sigma1l,CHIl,SHA512_Kl[i],SHA512_W_L[i]);const T1h=u64$1.add5H(T1ll,Hh,sigma1h,CHIh,SHA512_Kh[i],SHA512_W_H[i]);const T1l=T1ll|0;const sigma0h=u64$1.rotrSH(Ah,Al,28)^u64$1.rotrBH(Ah,Al,34)^u64$1.rotrBH(Ah,Al,39);const sigma0l=u64$1.rotrSL(Ah,Al,28)^u64$1.rotrBL(Ah,Al,34)^u64$1.rotrBL(Ah,Al,39);const MAJh=Ah&Bh^Ah&Ch^Bh&Ch;const MAJl=Al&Bl^Al&Cl^Bl&Cl;Hh=Gh|0;Hl=Gl|0;Gh=Fh|0;Gl=Fl|0;Fh=Eh|0;Fl=El|0;({h:Eh,l:El}=u64$1.add(Dh|0,Dl|0,T1h|0,T1l|0));Dh=Ch|0;Dl=Cl|0;Ch=Bh|0;Cl=Bl|0;Bh=Ah|0;Bl=Al|0;const All=u64$1.add3L(T1l,sigma0l,MAJl);Ah=u64$1.add3H(All,T1h,sigma0h,MAJh);Al=All|0}({h:Ah,l:Al}=u64$1.add(this.Ah|0,this.Al|0,Ah|0,Al|0));({h:Bh,l:Bl}=u64$1.add(this.Bh|0,this.Bl|0,Bh|0,Bl|0));({h:Ch,l:Cl}=u64$1.add(this.Ch|0,this.Cl|0,Ch|0,Cl|0));({h:Dh,l:Dl}=u64$1.add(this.Dh|0,this.Dl|0,Dh|0,Dl|0));({h:Eh,l:El}=u64$1.add(this.Eh|0,this.El|0,Eh|0,El|0));({h:Fh,l:Fl}=u64$1.add(this.Fh|0,this.Fl|0,Fh|0,Fl|0));({h:Gh,l:Gl}=u64$1.add(this.Gh|0,this.Gl|0,Gh|0,Gl|0));({h:Hh,l:Hl}=u64$1.add(this.Hh|0,this.Hl|0,Hh|0,Hl|0));this.set(Ah,Al,Bh,Bl,Ch,Cl,Dh,Dl,Eh,El,Fh,Fl,Gh,Gl,Hh,Hl)}roundClean(){SHA512_W_H.fill(0);SHA512_W_L.fill(0)}destroy(){this.buffer.fill(0);this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}}const sha512=wrapConstructor$1((()=>new SHA512)); +/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$5=BigInt(0);const _1n$7=BigInt(1);const _2n$5=BigInt(2);function isBytes$1(a){return a instanceof Uint8Array||ArrayBuffer.isView(a)&&a.constructor.name==="Uint8Array"}function abytes$1(item){if(!isBytes$1(item))throw new Error("Uint8Array expected")}function abool(title,value){if(typeof value!=="boolean")throw new Error(title+" boolean expected, got "+value)}const hexes=Array.from({length:256},((_,i)=>i.toString(16).padStart(2,"0")));function bytesToHex(bytes){abytes$1(bytes);let hex="";for(let i=0;i=asciis._0&&ch<=asciis._9)return ch-asciis._0;if(ch>=asciis.A&&ch<=asciis.F)return ch-(asciis.A-10);if(ch>=asciis.a&&ch<=asciis.f)return ch-(asciis.a-10);return}function hexToBytes(hex){if(typeof hex!=="string")throw new Error("hex string expected, got "+typeof hex);const hl=hex.length;const al=hl/2;if(hl%2)throw new Error("hex string expected, got unpadded hex of length "+hl);const array=new Uint8Array(al);for(let ai=0,hi=0;aitypeof n==="bigint"&&_0n$5<=n;function inRange(n,min,max){return isPosBig(n)&&isPosBig(min)&&isPosBig(max)&&min<=n&&n_0n$5;n>>=_1n$7,len+=1);return len}function bitGet(n,pos){return n>>BigInt(pos)&_1n$7}function bitSet(n,pos,value){return n|(value?_1n$7:_0n$5)<(_2n$5<new Uint8Array(data);const u8fr=arr=>Uint8Array.from(arr);function createHmacDrbg(hashLen,qByteLen,hmacFn){if(typeof hashLen!=="number"||hashLen<2)throw new Error("hashLen must be a number");if(typeof qByteLen!=="number"||qByteLen<2)throw new Error("qByteLen must be a number");if(typeof hmacFn!=="function")throw new Error("hmacFn must be a function");let v=u8n(hashLen);let k=u8n(hashLen);let i=0;const reset=()=>{v.fill(1);k.fill(0);i=0};const h=(...b)=>hmacFn(k,v,...b);const reseed=(seed=u8n())=>{k=h(u8fr([0]),seed);v=h();if(seed.length===0)return;k=h(u8fr([1]),seed);v=h()};const gen=()=>{if(i++>=1e3)throw new Error("drbg: tried 1000 values");let len=0;const out=[];while(len{reset();reseed(seed);let res=undefined;while(!(res=pred(gen())))reseed();reset();return res};return genUntil}const validatorFns={bigint:val=>typeof val==="bigint",function:val=>typeof val==="function",boolean:val=>typeof val==="boolean",string:val=>typeof val==="string",stringOrUint8Array:val=>typeof val==="string"||isBytes$1(val),isSafeInteger:val=>Number.isSafeInteger(val),array:val=>Array.isArray(val),field:(val,object)=>object.Fp.isValid(val),hash:val=>typeof val==="function"&&Number.isSafeInteger(val.outputLen)};function validateObject(object,validators,optValidators={}){const checkField=(fieldName,type,isOptional)=>{const checkVal=validatorFns[type];if(typeof checkVal!=="function")throw new Error("invalid validator function");const val=object[fieldName];if(isOptional&&val===undefined)return;if(!checkVal(val,object)){throw new Error("param "+String(fieldName)+" is invalid. Expected "+type+", got "+val)}};for(const[fieldName,type]of Object.entries(validators))checkField(fieldName,type,false);for(const[fieldName,type]of Object.entries(optValidators))checkField(fieldName,type,true);return object}const notImplemented=()=>{throw new Error("not implemented")};function memoized(fn){const map=new WeakMap;return(arg,...args)=>{const val=map.get(arg);if(val!==undefined)return val;const computed=fn(arg,...args);map.set(arg,computed);return computed}}var ut=Object.freeze({__proto__:null,aInRange:aInRange,abool:abool,abytes:abytes$1,bitGet:bitGet,bitLen:bitLen,bitMask:bitMask,bitSet:bitSet,bytesToHex:bytesToHex,bytesToNumberBE:bytesToNumberBE,bytesToNumberLE:bytesToNumberLE,concatBytes:concatBytes,createHmacDrbg:createHmacDrbg,ensureBytes:ensureBytes,equalBytes:equalBytes,hexToBytes:hexToBytes,hexToNumber:hexToNumber,inRange:inRange,isBytes:isBytes$1,memoized:memoized,notImplemented:notImplemented,numberToBytesBE:numberToBytesBE,numberToBytesLE:numberToBytesLE,numberToHexUnpadded:numberToHexUnpadded,numberToVarBytesBE:numberToVarBytesBE,utf8ToBytes:utf8ToBytes$1,validateObject:validateObject}); +/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$4=BigInt(0),_1n$6=BigInt(1),_2n$4=BigInt(2),_3n$1=BigInt(3);const _4n=BigInt(4),_5n$1=BigInt(5),_8n$2=BigInt(8);function mod(a,b){const result=a%b;return result>=_0n$4?result:b+result}function pow(num,power,modulo){if(power<_0n$4)throw new Error("invalid exponent, negatives unsupported");if(modulo<=_0n$4)throw new Error("invalid modulus");if(modulo===_1n$6)return _0n$4;let res=_1n$6;while(power>_0n$4){if(power&_1n$6)res=res*num%modulo;num=num*num%modulo;power>>=_1n$6}return res}function pow2(x,power,modulo){let res=x;while(power-- >_0n$4){res*=res;res%=modulo}return res}function invert(number,modulo){if(number===_0n$4)throw new Error("invert: expected non-zero number");if(modulo<=_0n$4)throw new Error("invert: expected positive modulus, got "+modulo);let a=mod(number,modulo);let b=modulo;let x=_0n$4,u=_1n$6;while(a!==_0n$4){const q=b/a;const r=b%a;const m=x-u*q;b=a,a=r,x=u,u=m}const gcd=b;if(gcd!==_1n$6)throw new Error("invert: does not exist");return mod(x,modulo)}function tonelliShanks(P){const legendreC=(P-_1n$6)/_2n$4;let Q,S,Z;for(Q=P-_1n$6,S=0;Q%_2n$4===_0n$4;Q/=_2n$4,S++);for(Z=_2n$4;Z1e3)throw new Error("Cannot find square root: likely non-prime P")}if(S===1){const p1div4=(P+_1n$6)/_4n;return function tonelliFast(Fp,n){const root=Fp.pow(n,p1div4);if(!Fp.eql(Fp.sqr(root),n))throw new Error("Cannot find square root");return root}}const Q1div2=(Q+_1n$6)/_2n$4;return function tonelliSlow(Fp,n){if(Fp.pow(n,legendreC)===Fp.neg(Fp.ONE))throw new Error("Cannot find square root");let r=S;let g=Fp.pow(Fp.mul(Fp.ONE,Z),Q);let x=Fp.pow(n,Q1div2);let b=Fp.pow(n,Q);while(!Fp.eql(b,Fp.ONE)){if(Fp.eql(b,Fp.ZERO))return Fp.ZERO;let m=1;for(let t2=Fp.sqr(b);m(mod(num,modulo)&_1n$6)===_1n$6;const FIELD_FIELDS=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"];function validateField(field){const initial={ORDER:"bigint",MASK:"bigint",BYTES:"isSafeInteger",BITS:"isSafeInteger"};const opts=FIELD_FIELDS.reduce(((map,val)=>{map[val]="function";return map}),initial);return validateObject(field,opts)}function FpPow(f,num,power){if(power<_0n$4)throw new Error("invalid exponent, negatives unsupported");if(power===_0n$4)return f.ONE;if(power===_1n$6)return num;let p=f.ONE;let d=num;while(power>_0n$4){if(power&_1n$6)p=f.mul(p,d);d=f.sqr(d);power>>=_1n$6}return p}function FpInvertBatch(f,nums){const tmp=new Array(nums.length);const lastMultiplied=nums.reduce(((acc,num,i)=>{if(f.is0(num))return acc;tmp[i]=acc;return f.mul(acc,num)}),f.ONE);const inverted=f.inv(lastMultiplied);nums.reduceRight(((acc,num,i)=>{if(f.is0(num))return acc;tmp[i]=f.mul(acc,tmp[i]);return f.mul(acc,num)}),inverted);return tmp}function nLength(n,nBitLength){const _nBitLength=nBitLength!==undefined?nBitLength:n.toString(2).length;const nByteLength=Math.ceil(_nBitLength/8);return{nBitLength:_nBitLength,nByteLength:nByteLength}}function Field(ORDER,bitLen,isLE=false,redef={}){if(ORDER<=_0n$4)throw new Error("invalid field: expected ORDER > 0, got "+ORDER);const{nBitLength:BITS,nByteLength:BYTES}=nLength(ORDER,bitLen);if(BYTES>2048)throw new Error("invalid field: expected ORDER of <= 2048 bytes");let sqrtP;const f=Object.freeze({ORDER:ORDER,BITS:BITS,BYTES:BYTES,MASK:bitMask(BITS),ZERO:_0n$4,ONE:_1n$6,create:num=>mod(num,ORDER),isValid:num=>{if(typeof num!=="bigint")throw new Error("invalid field element: expected bigint, got "+typeof num);return _0n$4<=num&&numnum===_0n$4,isOdd:num=>(num&_1n$6)===_1n$6,neg:num=>mod(-num,ORDER),eql:(lhs,rhs)=>lhs===rhs,sqr:num=>mod(num*num,ORDER),add:(lhs,rhs)=>mod(lhs+rhs,ORDER),sub:(lhs,rhs)=>mod(lhs-rhs,ORDER),mul:(lhs,rhs)=>mod(lhs*rhs,ORDER),pow:(num,power)=>FpPow(f,num,power),div:(lhs,rhs)=>mod(lhs*invert(rhs,ORDER),ORDER),sqrN:num=>num*num,addN:(lhs,rhs)=>lhs+rhs,subN:(lhs,rhs)=>lhs-rhs,mulN:(lhs,rhs)=>lhs*rhs,inv:num=>invert(num,ORDER),sqrt:redef.sqrt||(n=>{if(!sqrtP)sqrtP=FpSqrt(ORDER);return sqrtP(f,n)}),invertBatch:lst=>FpInvertBatch(f,lst),cmov:(a,b,c)=>c?b:a,toBytes:num=>isLE?numberToBytesLE(num,BYTES):numberToBytesBE(num,BYTES),fromBytes:bytes=>{if(bytes.length!==BYTES)throw new Error("Field.fromBytes: expected "+BYTES+" bytes, got "+bytes.length);return isLE?bytesToNumberLE(bytes):bytesToNumberBE(bytes)}});return Object.freeze(f)}function getFieldBytesLength(fieldOrder){if(typeof fieldOrder!=="bigint")throw new Error("field order must be bigint");const bitLength=fieldOrder.toString(2).length;return Math.ceil(bitLength/8)}function getMinHashLength(fieldOrder){const length=getFieldBytesLength(fieldOrder);return length+Math.ceil(length/2)}function mapHashToField(key,fieldOrder,isLE=false){const len=key.length;const fieldLen=getFieldBytesLength(fieldOrder);const minLen=getMinHashLength(fieldOrder);if(len<16||len1024)throw new Error("expected "+minLen+"-1024 bytes of input, got "+len);const num=isLE?bytesToNumberBE(key):bytesToNumberLE(key);const reduced=mod(num,fieldOrder-_1n$6)+_1n$6;return isLE?numberToBytesLE(reduced,fieldLen):numberToBytesBE(reduced,fieldLen)} +/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const _0n$3=BigInt(0);const _1n$5=BigInt(1);function constTimeNegate(condition,item){const neg=item.negate();return condition?neg:item}function validateW(W,bits){if(!Number.isSafeInteger(W)||W<=0||W>bits)throw new Error("invalid window size, expected [1.."+bits+"], got W="+W)}function calcWOpts(W,bits){validateW(W,bits);const windows=Math.ceil(bits/W)+1;const windowSize=2**(W-1);return{windows:windows,windowSize:windowSize}}function validateMSMPoints(points,c){if(!Array.isArray(points))throw new Error("array expected");points.forEach(((p,i)=>{if(!(p instanceof c))throw new Error("invalid point at index "+i)}))}function validateMSMScalars(scalars,field){if(!Array.isArray(scalars))throw new Error("array of scalars expected");scalars.forEach(((s,i)=>{if(!field.isValid(s))throw new Error("invalid scalar at index "+i)}))}const pointPrecomputes=new WeakMap;const pointWindowSizes=new WeakMap;function getW(P){return pointWindowSizes.get(P)||1}function wNAF(c,bits){return{constTimeNegate:constTimeNegate,hasPrecomputes(elm){return getW(elm)!==1},unsafeLadder(elm,n,p=c.ZERO){let d=elm;while(n>_0n$3){if(n&_1n$5)p=p.add(d);d=d.double();n>>=_1n$5}return p},precomputeWindow(elm,W){const{windows:windows,windowSize:windowSize}=calcWOpts(W,bits);const points=[];let p=elm;let base=p;for(let window=0;window>=shiftBy;if(wbits>windowSize){wbits-=maxNumber;n+=_1n$5}const offset1=offset;const offset2=offset+Math.abs(wbits)-1;const cond1=window%2!==0;const cond2=wbits<0;if(wbits===0){f=f.add(constTimeNegate(cond1,precomputes[offset1]))}else{p=p.add(constTimeNegate(cond2,precomputes[offset2]))}}return{p:p,f:f}},wNAFUnsafe(W,precomputes,n,acc=c.ZERO){const{windows:windows,windowSize:windowSize}=calcWOpts(W,bits);const mask=BigInt(2**W-1);const maxNumber=2**W;const shiftBy=BigInt(W);for(let window=0;window>=shiftBy;if(wbits>windowSize){wbits-=maxNumber;n+=_1n$5}if(wbits===0)continue;let curr=precomputes[offset+Math.abs(wbits)-1];if(wbits<0)curr=curr.negate();acc=acc.add(curr)}return acc},getPrecomputes(W,P,transform){let comp=pointPrecomputes.get(P);if(!comp){comp=this.precomputeWindow(P,W);if(W!==1)pointPrecomputes.set(P,transform(comp))}return comp},wNAFCached(P,n,transform){const W=getW(P);return this.wNAF(W,this.getPrecomputes(W,P,transform),n)},wNAFCachedUnsafe(P,n,transform,prev){const W=getW(P);if(W===1)return this.unsafeLadder(P,n,prev);return this.wNAFUnsafe(W,this.getPrecomputes(W,P,transform),n,prev)},setWindowSize(P,W){validateW(W,bits);pointWindowSizes.set(P,W);pointPrecomputes.delete(P)}}}function pippenger(c,fieldN,points,scalars){validateMSMPoints(points,c);validateMSMScalars(scalars,fieldN);if(points.length!==scalars.length)throw new Error("arrays of points and scalars must have equal length");const zero=c.ZERO;const wbits=bitLen(BigInt(points.length));const windowSize=wbits>12?wbits-3:wbits>4?wbits-2:wbits?2:1;const MASK=(1<=0;i-=windowSize){buckets.fill(zero);for(let j=0;j>BigInt(i)&BigInt(MASK));buckets[wbits]=buckets[wbits].add(points[j])}let resI=zero;for(let j=buckets.length-1,sumI=zero;j>0;j--){sumI=sumI.add(buckets[j]);resI=resI.add(sumI)}sum=sum.add(resI);if(i!==0)for(let j=0;j{try{return{isValid:true,value:Fp.sqrt(u*Fp.inv(v))}}catch(e){return{isValid:false,value:_0n$2}}});const adjustScalarBytes=CURVE.adjustScalarBytes||(bytes=>bytes);const domain=CURVE.domain||((data,ctx,phflag)=>{abool("phflag",phflag);if(ctx.length||phflag)throw new Error("Contexts/pre-hash are not supported");return data});function aCoordinate(title,n){aInRange("coordinate "+title,n,_0n$2,MASK)}function assertPoint(other){if(!(other instanceof Point))throw new Error("ExtendedPoint expected")}const toAffineMemo=memoized(((p,iz)=>{const{ex:x,ey:y,ez:z}=p;const is0=p.is0();if(iz==null)iz=is0?_8n$1:Fp.inv(z);const ax=modP(x*iz);const ay=modP(y*iz);const zz=modP(z*iz);if(is0)return{x:_0n$2,y:_1n$4};if(zz!==_1n$4)throw new Error("invZ was invalid");return{x:ax,y:ay}}));const assertValidMemo=memoized((p=>{const{a:a,d:d}=CURVE;if(p.is0())throw new Error("bad point: ZERO");const{ex:X,ey:Y,ez:Z,et:T}=p;const X2=modP(X*X);const Y2=modP(Y*Y);const Z2=modP(Z*Z);const Z4=modP(Z2*Z2);const aX2=modP(X2*a);const left=modP(Z2*modP(aX2+Y2));const right=modP(Z4+modP(d*modP(X2*Y2)));if(left!==right)throw new Error("bad point: equation left != right (1)");const XY=modP(X*Y);const ZT=modP(Z*T);if(XY!==ZT)throw new Error("bad point: equation left != right (2)");return true}));class Point{constructor(ex,ey,ez,et){this.ex=ex;this.ey=ey;this.ez=ez;this.et=et;aCoordinate("x",ex);aCoordinate("y",ey);aCoordinate("z",ez);aCoordinate("t",et);Object.freeze(this)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static fromAffine(p){if(p instanceof Point)throw new Error("extended point not allowed");const{x:x,y:y}=p||{};aCoordinate("x",x);aCoordinate("y",y);return new Point(x,y,_1n$4,modP(x*y))}static normalizeZ(points){const toInv=Fp.invertBatch(points.map((p=>p.ez)));return points.map(((p,i)=>p.toAffine(toInv[i]))).map(Point.fromAffine)}static msm(points,scalars){return pippenger(Point,Fn,points,scalars)}_setWindowSize(windowSize){wnaf.setWindowSize(this,windowSize)}assertValidity(){assertValidMemo(this)}equals(other){assertPoint(other);const{ex:X1,ey:Y1,ez:Z1}=this;const{ex:X2,ey:Y2,ez:Z2}=other;const X1Z2=modP(X1*Z2);const X2Z1=modP(X2*Z1);const Y1Z2=modP(Y1*Z2);const Y2Z1=modP(Y2*Z1);return X1Z2===X2Z1&&Y1Z2===Y2Z1}is0(){return this.equals(Point.ZERO)}negate(){return new Point(modP(-this.ex),this.ey,this.ez,modP(-this.et))}double(){const{a:a}=CURVE;const{ex:X1,ey:Y1,ez:Z1}=this;const A=modP(X1*X1);const B=modP(Y1*Y1);const C=modP(_2n$3*modP(Z1*Z1));const D=modP(a*A);const x1y1=X1+Y1;const E=modP(modP(x1y1*x1y1)-A-B);const G=D+B;const F=G-C;const H=D-B;const X3=modP(E*F);const Y3=modP(G*H);const T3=modP(E*H);const Z3=modP(F*G);return new Point(X3,Y3,Z3,T3)}add(other){assertPoint(other);const{a:a,d:d}=CURVE;const{ex:X1,ey:Y1,ez:Z1,et:T1}=this;const{ex:X2,ey:Y2,ez:Z2,et:T2}=other;if(a===BigInt(-1)){const A=modP((Y1-X1)*(Y2+X2));const B=modP((Y1+X1)*(Y2-X2));const F=modP(B-A);if(F===_0n$2)return this.double();const C=modP(Z1*_2n$3*T2);const D=modP(T1*_2n$3*Z2);const E=D+C;const G=B+A;const H=D-C;const X3=modP(E*F);const Y3=modP(G*H);const T3=modP(E*H);const Z3=modP(F*G);return new Point(X3,Y3,Z3,T3)}const A=modP(X1*X2);const B=modP(Y1*Y2);const C=modP(T1*d*T2);const D=modP(Z1*Z2);const E=modP((X1+Y1)*(X2+Y2)-A-B);const F=D-C;const G=D+C;const H=modP(B-a*A);const X3=modP(E*F);const Y3=modP(G*H);const T3=modP(E*H);const Z3=modP(F*G);return new Point(X3,Y3,Z3,T3)}subtract(other){return this.add(other.negate())}wNAF(n){return wnaf.wNAFCached(this,n,Point.normalizeZ)}multiply(scalar){const n=scalar;aInRange("scalar",n,_1n$4,CURVE_ORDER);const{p:p,f:f}=this.wNAF(n);return Point.normalizeZ([p,f])[0]}multiplyUnsafe(scalar,acc=Point.ZERO){const n=scalar;aInRange("scalar",n,_0n$2,CURVE_ORDER);if(n===_0n$2)return I;if(this.is0()||n===_1n$4)return this;return wnaf.wNAFCachedUnsafe(this,n,Point.normalizeZ,acc)}isSmallOrder(){return this.multiplyUnsafe(cofactor).is0()}isTorsionFree(){return wnaf.unsafeLadder(this,CURVE_ORDER).is0()}toAffine(iz){return toAffineMemo(this,iz)}clearCofactor(){const{h:cofactor}=CURVE;if(cofactor===_1n$4)return this;return this.multiplyUnsafe(cofactor)}static fromHex(hex,zip215=false){const{d:d,a:a}=CURVE;const len=Fp.BYTES;hex=ensureBytes("pointHex",hex,len);abool("zip215",zip215);const normed=hex.slice();const lastByte=hex[len-1];normed[len-1]=lastByte&~128;const y=bytesToNumberLE(normed);const max=zip215?MASK:Fp.ORDER;aInRange("pointHex.y",y,_0n$2,max);const y2=modP(y*y);const u=modP(y2-_1n$4);const v=modP(d*y2-a);let{isValid:isValid,value:x}=uvRatio(u,v);if(!isValid)throw new Error("Point.fromHex: invalid y coordinate");const isXOdd=(x&_1n$4)===_1n$4;const isLastByteOdd=(lastByte&128)!==0;if(!zip215&&x===_0n$2&&isLastByteOdd)throw new Error("Point.fromHex: x=0 and x_0=1");if(isLastByteOdd!==isXOdd)x=modP(-x);return Point.fromAffine({x:x,y:y})}static fromPrivateKey(privKey){return getExtendedPublicKey(privKey).point}toRawBytes(){const{x:x,y:y}=this.toAffine();const bytes=numberToBytesLE(y,Fp.BYTES);bytes[bytes.length-1]|=x&_1n$4?128:0;return bytes}toHex(){return bytesToHex(this.toRawBytes())}}Point.BASE=new Point(CURVE.Gx,CURVE.Gy,_1n$4,modP(CURVE.Gx*CURVE.Gy));Point.ZERO=new Point(_0n$2,_1n$4,_1n$4,_0n$2);const{BASE:G,ZERO:I}=Point;const wnaf=wNAF(Point,nByteLength*8);function modN(a){return mod(a,CURVE_ORDER)}function modN_LE(hash){return modN(bytesToNumberLE(hash))}function getExtendedPublicKey(key){const len=Fp.BYTES;key=ensureBytes("private key",key,len);const hashed=ensureBytes("hashed private key",cHash(key),2*len);const head=adjustScalarBytes(hashed.slice(0,len));const prefix=hashed.slice(len,2*len);const scalar=modN_LE(head);const point=G.multiply(scalar);const pointBytes=point.toRawBytes();return{head:head,prefix:prefix,scalar:scalar,point:point,pointBytes:pointBytes}}function getPublicKey(privKey){return getExtendedPublicKey(privKey).pointBytes}function hashDomainToScalar(context=new Uint8Array,...msgs){const msg=concatBytes(...msgs);return modN_LE(cHash(domain(msg,ensureBytes("context",context),!!prehash)))}function sign(msg,privKey,options={}){msg=ensureBytes("message",msg);if(prehash)msg=prehash(msg);const{prefix:prefix,scalar:scalar,pointBytes:pointBytes}=getExtendedPublicKey(privKey);const r=hashDomainToScalar(options.context,prefix,msg);const R=G.multiply(r).toRawBytes();const k=hashDomainToScalar(options.context,R,pointBytes,msg);const s=modN(r+k*scalar);aInRange("signature.s",s,_0n$2,CURVE_ORDER);const res=concatBytes(R,numberToBytesLE(s,Fp.BYTES));return ensureBytes("result",res,Fp.BYTES*2)}const verifyOpts=VERIFY_DEFAULT;function verify(sig,msg,publicKey,options=verifyOpts){const{context:context,zip215:zip215}=options;const len=Fp.BYTES;sig=ensureBytes("signature",sig,2*len);msg=ensureBytes("message",msg);publicKey=ensureBytes("publicKey",publicKey,len);if(zip215!==undefined)abool("zip215",zip215);if(prehash)msg=prehash(msg);const s=bytesToNumberLE(sig.slice(len,2*len));let A,R,SB;try{A=Point.fromHex(publicKey,zip215);R=Point.fromHex(sig.slice(0,len),zip215);SB=G.multiplyUnsafe(s)}catch(error){return false}if(!zip215&&A.isSmallOrder())return false;const k=hashDomainToScalar(context,R.toRawBytes(),A.toRawBytes(),msg);const RkA=R.add(A.multiplyUnsafe(k));return RkA.subtract(SB).clearCofactor().equals(Point.ZERO)}G._setWindowSize(8);const utils={getExtendedPublicKey:getExtendedPublicKey,randomPrivateKey:()=>randomBytes(Fp.BYTES),precompute(windowSize=8,point=Point.BASE){point._setWindowSize(windowSize);point.multiply(BigInt(3));return point}};return{CURVE:CURVE,getPublicKey:getPublicKey,sign:sign,verify:verify,ExtendedPoint:Point,utils:utils}} +/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const ED25519_P=BigInt("57896044618658097711785492504343953926634992332820282019728792003956564819949");const ED25519_SQRT_M1=BigInt("19681161376707505956807079304988542015446066515923890162744021073123829784752");BigInt(0);const _1n$3=BigInt(1),_2n$2=BigInt(2);BigInt(3);const _5n=BigInt(5),_8n=BigInt(8);function ed25519_pow_2_252_3(x){const _10n=BigInt(10),_20n=BigInt(20),_40n=BigInt(40),_80n=BigInt(80);const P=ED25519_P;const x2=x*x%P;const b2=x2*x%P;const b4=pow2(b2,_2n$2,P)*b2%P;const b5=pow2(b4,_1n$3,P)*x%P;const b10=pow2(b5,_5n,P)*b5%P;const b20=pow2(b10,_10n,P)*b10%P;const b40=pow2(b20,_20n,P)*b20%P;const b80=pow2(b40,_40n,P)*b40%P;const b160=pow2(b80,_80n,P)*b80%P;const b240=pow2(b160,_80n,P)*b80%P;const b250=pow2(b240,_10n,P)*b10%P;const pow_p_5_8=pow2(b250,_2n$2,P)*x%P;return{pow_p_5_8:pow_p_5_8,b2:b2}}function adjustScalarBytes(bytes){bytes[0]&=248;bytes[31]&=127;bytes[31]|=64;return bytes}function uvRatio(u,v){const P=ED25519_P;const v3=mod(v*v*v,P);const v7=mod(v3*v3*v,P);const pow=ed25519_pow_2_252_3(u*v7).pow_p_5_8;let x=mod(u*v3*pow,P);const vx2=mod(v*x*x,P);const root1=x;const root2=mod(x*ED25519_SQRT_M1,P);const useRoot1=vx2===u;const useRoot2=vx2===mod(-u,P);const noRoot=vx2===mod(-u*ED25519_SQRT_M1,P);if(useRoot1)x=root1;if(useRoot2||noRoot)x=root2;if(isNegativeLE(x,P))x=mod(-x,P);return{isValid:useRoot1||useRoot2,value:x}}const Fp=(()=>Field(ED25519_P,undefined,true))();const ed25519Defaults=(()=>({a:BigInt(-1),d:BigInt("37095705934669439343138083508754565189542113879843219016388785533085940283555"),Fp:Fp,n:BigInt("7237005577332262213973186563042994240857116359379907606001950938285454250989"),h:_8n,Gx:BigInt("15112221349535400772501151409588531511454012693041857206046113283949847762202"),Gy:BigInt("46316835694926478169428394003475163141307993866256225615783033603165251855960"),hash:sha512,randomBytes:randomBytes,adjustScalarBytes:adjustScalarBytes,uvRatio:uvRatio}))();const ed25519=(()=>twistedEdwards(ed25519Defaults))();const generatePrivateKey=ed25519.utils.randomPrivateKey;const generateKeypair=()=>{const privateScalar=ed25519.utils.randomPrivateKey();const publicKey=getPublicKey(privateScalar);const secretKey=new Uint8Array(64);secretKey.set(privateScalar);secretKey.set(publicKey,32);return{publicKey:publicKey,secretKey:secretKey}};const getPublicKey=ed25519.getPublicKey;function isOnCurve(publicKey){try{ed25519.ExtendedPoint.fromHex(publicKey);return true}catch{return false}}const sign=(message,secretKey)=>ed25519.sign(message,secretKey.slice(0,32));const verify=ed25519.verify;const toBuffer=arr=>{if(bufferExports.Buffer.isBuffer(arr)){return arr}else if(arr instanceof Uint8Array){return bufferExports.Buffer.from(arr.buffer,arr.byteOffset,arr.byteLength)}else{return bufferExports.Buffer.from(arr)}};var bn$1={exports:{}};var _nodeResolve_empty={};var _nodeResolve_empty$1=Object.freeze({__proto__:null,default:_nodeResolve_empty});var require$$0$1=getAugmentedNamespace(_nodeResolve_empty$1);var bn=bn$1.exports;var hasRequiredBn;function requireBn(){if(hasRequiredBn)return bn$1.exports;hasRequiredBn=1;(function(module){(function(module,exports){function assert(val,msg){if(!val)throw new Error(msg||"Assertion failed")}function inherits(ctor,superCtor){ctor.super_=superCtor;var TempCtor=function(){};TempCtor.prototype=superCtor.prototype;ctor.prototype=new TempCtor;ctor.prototype.constructor=ctor}function BN(number,base,endian){if(BN.isBN(number)){return number}this.negative=0;this.words=null;this.length=0;this.red=null;if(number!==null){if(base==="le"||base==="be"){endian=base;base=10}this._init(number||0,base||10,endian||"be")}}if(typeof module==="object"){module.exports=BN}else{exports.BN=BN}BN.BN=BN;BN.wordSize=26;var Buffer;try{if(typeof window!=="undefined"&&typeof window.Buffer!=="undefined"){Buffer=window.Buffer}else{Buffer=require$$0$1.Buffer}}catch(e){}BN.isBN=function isBN(num){if(num instanceof BN){return true}return num!==null&&typeof num==="object"&&num.constructor.wordSize===BN.wordSize&&Array.isArray(num.words)};BN.max=function max(left,right){if(left.cmp(right)>0)return left;return right};BN.min=function min(left,right){if(left.cmp(right)<0)return left;return right};BN.prototype._init=function init(number,base,endian){if(typeof number==="number"){return this._initNumber(number,base,endian)}if(typeof number==="object"){return this._initArray(number,base,endian)}if(base==="hex"){base=16}assert(base===(base|0)&&base>=2&&base<=36);number=number.toString().replace(/\s+/g,"");var start=0;if(number[0]==="-"){start++;this.negative=1}if(start=0;i-=3){w=number[i]|number[i-1]<<8|number[i-2]<<16;this.words[j]|=w<>>26-off&67108863;off+=24;if(off>=26){off-=26;j++}}}else if(endian==="le"){for(i=0,j=0;i>>26-off&67108863;off+=24;if(off>=26){off-=26;j++}}}return this._strip()};function parseHex4Bits(string,index){var c=string.charCodeAt(index);if(c>=48&&c<=57){return c-48}else if(c>=65&&c<=70){return c-55}else if(c>=97&&c<=102){return c-87}else{assert(false,"Invalid character in "+string)}}function parseHexByte(string,lowerBound,index){var r=parseHex4Bits(string,index);if(index-1>=lowerBound){r|=parseHex4Bits(string,index-1)<<4}return r}BN.prototype._parseHex=function _parseHex(number,start,endian){this.length=Math.ceil((number.length-start)/6);this.words=new Array(this.length);for(var i=0;i=start;i-=2){w=parseHexByte(number,start,i)<=18){off-=18;j+=1;this.words[j]|=w>>>26}else{off+=8}}}else{var parseLength=number.length-start;for(i=parseLength%2===0?start+1:start;i=18){off-=18;j+=1;this.words[j]|=w>>>26}else{off+=8}}}this._strip()};function parseBase(str,start,end,mul){var r=0;var b=0;var len=Math.min(str.length,end);for(var i=start;i=49){b=c-49+10}else if(c>=17){b=c-17+10}else{b=c}assert(c>=0&&b1&&this.words[this.length-1]===0){this.length--}return this._normSign()};BN.prototype._normSign=function _normSign(){if(this.length===1&&this.words[0]===0){this.negative=0}return this};if(typeof Symbol!=="undefined"&&typeof Symbol.for==="function"){try{BN.prototype[Symbol.for("nodejs.util.inspect.custom")]=inspect}catch(e){BN.prototype.inspect=inspect}}else{BN.prototype.inspect=inspect}function inspect(){return(this.red?""}var zeros=["","0","00","000","0000","00000","000000","0000000","00000000","000000000","0000000000","00000000000","000000000000","0000000000000","00000000000000","000000000000000","0000000000000000","00000000000000000","000000000000000000","0000000000000000000","00000000000000000000","000000000000000000000","0000000000000000000000","00000000000000000000000","000000000000000000000000","0000000000000000000000000"];var groupSizes=[0,0,25,16,12,11,10,9,8,8,7,7,7,7,6,6,6,6,6,6,6,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5];var groupBases=[0,0,33554432,43046721,16777216,48828125,60466176,40353607,16777216,43046721,1e7,19487171,35831808,62748517,7529536,11390625,16777216,24137569,34012224,47045881,64e6,4084101,5153632,6436343,7962624,9765625,11881376,14348907,17210368,20511149,243e5,28629151,33554432,39135393,45435424,52521875,60466176];BN.prototype.toString=function toString(base,padding){base=base||10;padding=padding|0||1;var out;if(base===16||base==="hex"){out="";var off=0;var carry=0;for(var i=0;i>>24-off&16777215;off+=2;if(off>=26){off-=26;i--}if(carry!==0||i!==this.length-1){out=zeros[6-word.length]+word+out}else{out=word+out}}if(carry!==0){out=carry.toString(16)+out}while(out.length%padding!==0){out="0"+out}if(this.negative!==0){out="-"+out}return out}if(base===(base|0)&&base>=2&&base<=36){var groupSize=groupSizes[base];var groupBase=groupBases[base];out="";var c=this.clone();c.negative=0;while(!c.isZero()){var r=c.modrn(groupBase).toString(base);c=c.idivn(groupBase);if(!c.isZero()){out=zeros[groupSize-r.length]+r+out}else{out=r+out}}if(this.isZero()){out="0"+out}while(out.length%padding!==0){out="0"+out}if(this.negative!==0){out="-"+out}return out}assert(false,"Base should be between 2 and 36")};BN.prototype.toNumber=function toNumber(){var ret=this.words[0];if(this.length===2){ret+=this.words[1]*67108864}else if(this.length===3&&this.words[2]===1){ret+=4503599627370496+this.words[1]*67108864}else if(this.length>2){assert(false,"Number can only safely store up to 53 bits")}return this.negative!==0?-ret:ret};BN.prototype.toJSON=function toJSON(){return this.toString(16,2)};if(Buffer){BN.prototype.toBuffer=function toBuffer(endian,length){return this.toArrayLike(Buffer,endian,length)}}BN.prototype.toArray=function toArray(endian,length){return this.toArrayLike(Array,endian,length)};var allocate=function allocate(ArrayType,size){if(ArrayType.allocUnsafe){return ArrayType.allocUnsafe(size)}return new ArrayType(size)};BN.prototype.toArrayLike=function toArrayLike(ArrayType,endian,length){this._strip();var byteLength=this.byteLength();var reqLength=length||Math.max(1,byteLength);assert(byteLength<=reqLength,"byte array longer than desired length");assert(reqLength>0,"Requested array length <= 0");var res=allocate(ArrayType,reqLength);var postfix=endian==="le"?"LE":"BE";this["_toArrayLike"+postfix](res,byteLength);return res};BN.prototype._toArrayLikeLE=function _toArrayLikeLE(res,byteLength){var position=0;var carry=0;for(var i=0,shift=0;i>8&255}if(position>16&255}if(shift===6){if(position>24&255}carry=0;shift=0}else{carry=word>>>24;shift+=2}}if(position=0){res[position--]=word>>8&255}if(position>=0){res[position--]=word>>16&255}if(shift===6){if(position>=0){res[position--]=word>>24&255}carry=0;shift=0}else{carry=word>>>24;shift+=2}}if(position>=0){res[position--]=carry;while(position>=0){res[position--]=0}}};if(Math.clz32){BN.prototype._countBits=function _countBits(w){return 32-Math.clz32(w)}}else{BN.prototype._countBits=function _countBits(w){var t=w;var r=0;if(t>=4096){r+=13;t>>>=13}if(t>=64){r+=7;t>>>=7}if(t>=8){r+=4;t>>>=4}if(t>=2){r+=2;t>>>=2}return r+t}}BN.prototype._zeroBits=function _zeroBits(w){if(w===0)return 26;var t=w;var r=0;if((t&8191)===0){r+=13;t>>>=13}if((t&127)===0){r+=7;t>>>=7}if((t&15)===0){r+=4;t>>>=4}if((t&3)===0){r+=2;t>>>=2}if((t&1)===0){r++}return r};BN.prototype.bitLength=function bitLength(){var w=this.words[this.length-1];var hi=this._countBits(w);return(this.length-1)*26+hi};function toBitArray(num){var w=new Array(num.bitLength());for(var bit=0;bit>>wbit&1}return w}BN.prototype.zeroBits=function zeroBits(){if(this.isZero())return 0;var r=0;for(var i=0;inum.length)return this.clone().ior(num);return num.clone().ior(this)};BN.prototype.uor=function uor(num){if(this.length>num.length)return this.clone().iuor(num);return num.clone().iuor(this)};BN.prototype.iuand=function iuand(num){var b;if(this.length>num.length){b=num}else{b=this}for(var i=0;inum.length)return this.clone().iand(num);return num.clone().iand(this)};BN.prototype.uand=function uand(num){if(this.length>num.length)return this.clone().iuand(num);return num.clone().iuand(this)};BN.prototype.iuxor=function iuxor(num){var a;var b;if(this.length>num.length){a=this;b=num}else{a=num;b=this}for(var i=0;inum.length)return this.clone().ixor(num);return num.clone().ixor(this)};BN.prototype.uxor=function uxor(num){if(this.length>num.length)return this.clone().iuxor(num);return num.clone().iuxor(this)};BN.prototype.inotn=function inotn(width){assert(typeof width==="number"&&width>=0);var bytesNeeded=Math.ceil(width/26)|0;var bitsLeft=width%26;this._expand(bytesNeeded);if(bitsLeft>0){bytesNeeded--}for(var i=0;i0){this.words[i]=~this.words[i]&67108863>>26-bitsLeft}return this._strip()};BN.prototype.notn=function notn(width){return this.clone().inotn(width)};BN.prototype.setn=function setn(bit,val){assert(typeof bit==="number"&&bit>=0);var off=bit/26|0;var wbit=bit%26;this._expand(off+1);if(val){this.words[off]=this.words[off]|1<num.length){a=this;b=num}else{a=num;b=this}var carry=0;for(var i=0;i>>26}for(;carry!==0&&i>>26}this.length=a.length;if(carry!==0){this.words[this.length]=carry;this.length++}else if(a!==this){for(;inum.length)return this.clone().iadd(num);return num.clone().iadd(this)};BN.prototype.isub=function isub(num){if(num.negative!==0){num.negative=0;var r=this.iadd(num);num.negative=1;return r._normSign()}else if(this.negative!==0){this.negative=0;this.iadd(num);this.negative=1;return this._normSign()}var cmp=this.cmp(num);if(cmp===0){this.negative=0;this.length=1;this.words[0]=0;return this}var a,b;if(cmp>0){a=this;b=num}else{a=num;b=this}var carry=0;for(var i=0;i>26;this.words[i]=r&67108863}for(;carry!==0&&i>26;this.words[i]=r&67108863}if(carry===0&&i>>26;var rword=carry&67108863;var maxJ=Math.min(k,num.length-1);for(var j=Math.max(0,k-self.length+1);j<=maxJ;j++){var i=k-j|0;a=self.words[i]|0;b=num.words[j]|0;r=a*b+rword;ncarry+=r/67108864|0;rword=r&67108863}out.words[k]=rword|0;carry=ncarry|0}if(carry!==0){out.words[k]=carry|0}else{out.length--}return out._strip()}var comb10MulTo=function comb10MulTo(self,num,out){var a=self.words;var b=num.words;var o=out.words;var c=0;var lo;var mid;var hi;var a0=a[0]|0;var al0=a0&8191;var ah0=a0>>>13;var a1=a[1]|0;var al1=a1&8191;var ah1=a1>>>13;var a2=a[2]|0;var al2=a2&8191;var ah2=a2>>>13;var a3=a[3]|0;var al3=a3&8191;var ah3=a3>>>13;var a4=a[4]|0;var al4=a4&8191;var ah4=a4>>>13;var a5=a[5]|0;var al5=a5&8191;var ah5=a5>>>13;var a6=a[6]|0;var al6=a6&8191;var ah6=a6>>>13;var a7=a[7]|0;var al7=a7&8191;var ah7=a7>>>13;var a8=a[8]|0;var al8=a8&8191;var ah8=a8>>>13;var a9=a[9]|0;var al9=a9&8191;var ah9=a9>>>13;var b0=b[0]|0;var bl0=b0&8191;var bh0=b0>>>13;var b1=b[1]|0;var bl1=b1&8191;var bh1=b1>>>13;var b2=b[2]|0;var bl2=b2&8191;var bh2=b2>>>13;var b3=b[3]|0;var bl3=b3&8191;var bh3=b3>>>13;var b4=b[4]|0;var bl4=b4&8191;var bh4=b4>>>13;var b5=b[5]|0;var bl5=b5&8191;var bh5=b5>>>13;var b6=b[6]|0;var bl6=b6&8191;var bh6=b6>>>13;var b7=b[7]|0;var bl7=b7&8191;var bh7=b7>>>13;var b8=b[8]|0;var bl8=b8&8191;var bh8=b8>>>13;var b9=b[9]|0;var bl9=b9&8191;var bh9=b9>>>13;out.negative=self.negative^num.negative;out.length=19;lo=Math.imul(al0,bl0);mid=Math.imul(al0,bh0);mid=mid+Math.imul(ah0,bl0)|0;hi=Math.imul(ah0,bh0);var w0=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w0>>>26)|0;w0&=67108863;lo=Math.imul(al1,bl0);mid=Math.imul(al1,bh0);mid=mid+Math.imul(ah1,bl0)|0;hi=Math.imul(ah1,bh0);lo=lo+Math.imul(al0,bl1)|0;mid=mid+Math.imul(al0,bh1)|0;mid=mid+Math.imul(ah0,bl1)|0;hi=hi+Math.imul(ah0,bh1)|0;var w1=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w1>>>26)|0;w1&=67108863;lo=Math.imul(al2,bl0);mid=Math.imul(al2,bh0);mid=mid+Math.imul(ah2,bl0)|0;hi=Math.imul(ah2,bh0);lo=lo+Math.imul(al1,bl1)|0;mid=mid+Math.imul(al1,bh1)|0;mid=mid+Math.imul(ah1,bl1)|0;hi=hi+Math.imul(ah1,bh1)|0;lo=lo+Math.imul(al0,bl2)|0;mid=mid+Math.imul(al0,bh2)|0;mid=mid+Math.imul(ah0,bl2)|0;hi=hi+Math.imul(ah0,bh2)|0;var w2=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w2>>>26)|0;w2&=67108863;lo=Math.imul(al3,bl0);mid=Math.imul(al3,bh0);mid=mid+Math.imul(ah3,bl0)|0;hi=Math.imul(ah3,bh0);lo=lo+Math.imul(al2,bl1)|0;mid=mid+Math.imul(al2,bh1)|0;mid=mid+Math.imul(ah2,bl1)|0;hi=hi+Math.imul(ah2,bh1)|0;lo=lo+Math.imul(al1,bl2)|0;mid=mid+Math.imul(al1,bh2)|0;mid=mid+Math.imul(ah1,bl2)|0;hi=hi+Math.imul(ah1,bh2)|0;lo=lo+Math.imul(al0,bl3)|0;mid=mid+Math.imul(al0,bh3)|0;mid=mid+Math.imul(ah0,bl3)|0;hi=hi+Math.imul(ah0,bh3)|0;var w3=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w3>>>26)|0;w3&=67108863;lo=Math.imul(al4,bl0);mid=Math.imul(al4,bh0);mid=mid+Math.imul(ah4,bl0)|0;hi=Math.imul(ah4,bh0);lo=lo+Math.imul(al3,bl1)|0;mid=mid+Math.imul(al3,bh1)|0;mid=mid+Math.imul(ah3,bl1)|0;hi=hi+Math.imul(ah3,bh1)|0;lo=lo+Math.imul(al2,bl2)|0;mid=mid+Math.imul(al2,bh2)|0;mid=mid+Math.imul(ah2,bl2)|0;hi=hi+Math.imul(ah2,bh2)|0;lo=lo+Math.imul(al1,bl3)|0;mid=mid+Math.imul(al1,bh3)|0;mid=mid+Math.imul(ah1,bl3)|0;hi=hi+Math.imul(ah1,bh3)|0;lo=lo+Math.imul(al0,bl4)|0;mid=mid+Math.imul(al0,bh4)|0;mid=mid+Math.imul(ah0,bl4)|0;hi=hi+Math.imul(ah0,bh4)|0;var w4=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w4>>>26)|0;w4&=67108863;lo=Math.imul(al5,bl0);mid=Math.imul(al5,bh0);mid=mid+Math.imul(ah5,bl0)|0;hi=Math.imul(ah5,bh0);lo=lo+Math.imul(al4,bl1)|0;mid=mid+Math.imul(al4,bh1)|0;mid=mid+Math.imul(ah4,bl1)|0;hi=hi+Math.imul(ah4,bh1)|0;lo=lo+Math.imul(al3,bl2)|0;mid=mid+Math.imul(al3,bh2)|0;mid=mid+Math.imul(ah3,bl2)|0;hi=hi+Math.imul(ah3,bh2)|0;lo=lo+Math.imul(al2,bl3)|0;mid=mid+Math.imul(al2,bh3)|0;mid=mid+Math.imul(ah2,bl3)|0;hi=hi+Math.imul(ah2,bh3)|0;lo=lo+Math.imul(al1,bl4)|0;mid=mid+Math.imul(al1,bh4)|0;mid=mid+Math.imul(ah1,bl4)|0;hi=hi+Math.imul(ah1,bh4)|0;lo=lo+Math.imul(al0,bl5)|0;mid=mid+Math.imul(al0,bh5)|0;mid=mid+Math.imul(ah0,bl5)|0;hi=hi+Math.imul(ah0,bh5)|0;var w5=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w5>>>26)|0;w5&=67108863;lo=Math.imul(al6,bl0);mid=Math.imul(al6,bh0);mid=mid+Math.imul(ah6,bl0)|0;hi=Math.imul(ah6,bh0);lo=lo+Math.imul(al5,bl1)|0;mid=mid+Math.imul(al5,bh1)|0;mid=mid+Math.imul(ah5,bl1)|0;hi=hi+Math.imul(ah5,bh1)|0;lo=lo+Math.imul(al4,bl2)|0;mid=mid+Math.imul(al4,bh2)|0;mid=mid+Math.imul(ah4,bl2)|0;hi=hi+Math.imul(ah4,bh2)|0;lo=lo+Math.imul(al3,bl3)|0;mid=mid+Math.imul(al3,bh3)|0;mid=mid+Math.imul(ah3,bl3)|0;hi=hi+Math.imul(ah3,bh3)|0;lo=lo+Math.imul(al2,bl4)|0;mid=mid+Math.imul(al2,bh4)|0;mid=mid+Math.imul(ah2,bl4)|0;hi=hi+Math.imul(ah2,bh4)|0;lo=lo+Math.imul(al1,bl5)|0;mid=mid+Math.imul(al1,bh5)|0;mid=mid+Math.imul(ah1,bl5)|0;hi=hi+Math.imul(ah1,bh5)|0;lo=lo+Math.imul(al0,bl6)|0;mid=mid+Math.imul(al0,bh6)|0;mid=mid+Math.imul(ah0,bl6)|0;hi=hi+Math.imul(ah0,bh6)|0;var w6=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w6>>>26)|0;w6&=67108863;lo=Math.imul(al7,bl0);mid=Math.imul(al7,bh0);mid=mid+Math.imul(ah7,bl0)|0;hi=Math.imul(ah7,bh0);lo=lo+Math.imul(al6,bl1)|0;mid=mid+Math.imul(al6,bh1)|0;mid=mid+Math.imul(ah6,bl1)|0;hi=hi+Math.imul(ah6,bh1)|0;lo=lo+Math.imul(al5,bl2)|0;mid=mid+Math.imul(al5,bh2)|0;mid=mid+Math.imul(ah5,bl2)|0;hi=hi+Math.imul(ah5,bh2)|0;lo=lo+Math.imul(al4,bl3)|0;mid=mid+Math.imul(al4,bh3)|0;mid=mid+Math.imul(ah4,bl3)|0;hi=hi+Math.imul(ah4,bh3)|0;lo=lo+Math.imul(al3,bl4)|0;mid=mid+Math.imul(al3,bh4)|0;mid=mid+Math.imul(ah3,bl4)|0;hi=hi+Math.imul(ah3,bh4)|0;lo=lo+Math.imul(al2,bl5)|0;mid=mid+Math.imul(al2,bh5)|0;mid=mid+Math.imul(ah2,bl5)|0;hi=hi+Math.imul(ah2,bh5)|0;lo=lo+Math.imul(al1,bl6)|0;mid=mid+Math.imul(al1,bh6)|0;mid=mid+Math.imul(ah1,bl6)|0;hi=hi+Math.imul(ah1,bh6)|0;lo=lo+Math.imul(al0,bl7)|0;mid=mid+Math.imul(al0,bh7)|0;mid=mid+Math.imul(ah0,bl7)|0;hi=hi+Math.imul(ah0,bh7)|0;var w7=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w7>>>26)|0;w7&=67108863;lo=Math.imul(al8,bl0);mid=Math.imul(al8,bh0);mid=mid+Math.imul(ah8,bl0)|0;hi=Math.imul(ah8,bh0);lo=lo+Math.imul(al7,bl1)|0;mid=mid+Math.imul(al7,bh1)|0;mid=mid+Math.imul(ah7,bl1)|0;hi=hi+Math.imul(ah7,bh1)|0;lo=lo+Math.imul(al6,bl2)|0;mid=mid+Math.imul(al6,bh2)|0;mid=mid+Math.imul(ah6,bl2)|0;hi=hi+Math.imul(ah6,bh2)|0;lo=lo+Math.imul(al5,bl3)|0;mid=mid+Math.imul(al5,bh3)|0;mid=mid+Math.imul(ah5,bl3)|0;hi=hi+Math.imul(ah5,bh3)|0;lo=lo+Math.imul(al4,bl4)|0;mid=mid+Math.imul(al4,bh4)|0;mid=mid+Math.imul(ah4,bl4)|0;hi=hi+Math.imul(ah4,bh4)|0;lo=lo+Math.imul(al3,bl5)|0;mid=mid+Math.imul(al3,bh5)|0;mid=mid+Math.imul(ah3,bl5)|0;hi=hi+Math.imul(ah3,bh5)|0;lo=lo+Math.imul(al2,bl6)|0;mid=mid+Math.imul(al2,bh6)|0;mid=mid+Math.imul(ah2,bl6)|0;hi=hi+Math.imul(ah2,bh6)|0;lo=lo+Math.imul(al1,bl7)|0;mid=mid+Math.imul(al1,bh7)|0;mid=mid+Math.imul(ah1,bl7)|0;hi=hi+Math.imul(ah1,bh7)|0;lo=lo+Math.imul(al0,bl8)|0;mid=mid+Math.imul(al0,bh8)|0;mid=mid+Math.imul(ah0,bl8)|0;hi=hi+Math.imul(ah0,bh8)|0;var w8=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w8>>>26)|0;w8&=67108863;lo=Math.imul(al9,bl0);mid=Math.imul(al9,bh0);mid=mid+Math.imul(ah9,bl0)|0;hi=Math.imul(ah9,bh0);lo=lo+Math.imul(al8,bl1)|0;mid=mid+Math.imul(al8,bh1)|0;mid=mid+Math.imul(ah8,bl1)|0;hi=hi+Math.imul(ah8,bh1)|0;lo=lo+Math.imul(al7,bl2)|0;mid=mid+Math.imul(al7,bh2)|0;mid=mid+Math.imul(ah7,bl2)|0;hi=hi+Math.imul(ah7,bh2)|0;lo=lo+Math.imul(al6,bl3)|0;mid=mid+Math.imul(al6,bh3)|0;mid=mid+Math.imul(ah6,bl3)|0;hi=hi+Math.imul(ah6,bh3)|0;lo=lo+Math.imul(al5,bl4)|0;mid=mid+Math.imul(al5,bh4)|0;mid=mid+Math.imul(ah5,bl4)|0;hi=hi+Math.imul(ah5,bh4)|0;lo=lo+Math.imul(al4,bl5)|0;mid=mid+Math.imul(al4,bh5)|0;mid=mid+Math.imul(ah4,bl5)|0;hi=hi+Math.imul(ah4,bh5)|0;lo=lo+Math.imul(al3,bl6)|0;mid=mid+Math.imul(al3,bh6)|0;mid=mid+Math.imul(ah3,bl6)|0;hi=hi+Math.imul(ah3,bh6)|0;lo=lo+Math.imul(al2,bl7)|0;mid=mid+Math.imul(al2,bh7)|0;mid=mid+Math.imul(ah2,bl7)|0;hi=hi+Math.imul(ah2,bh7)|0;lo=lo+Math.imul(al1,bl8)|0;mid=mid+Math.imul(al1,bh8)|0;mid=mid+Math.imul(ah1,bl8)|0;hi=hi+Math.imul(ah1,bh8)|0;lo=lo+Math.imul(al0,bl9)|0;mid=mid+Math.imul(al0,bh9)|0;mid=mid+Math.imul(ah0,bl9)|0;hi=hi+Math.imul(ah0,bh9)|0;var w9=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w9>>>26)|0;w9&=67108863;lo=Math.imul(al9,bl1);mid=Math.imul(al9,bh1);mid=mid+Math.imul(ah9,bl1)|0;hi=Math.imul(ah9,bh1);lo=lo+Math.imul(al8,bl2)|0;mid=mid+Math.imul(al8,bh2)|0;mid=mid+Math.imul(ah8,bl2)|0;hi=hi+Math.imul(ah8,bh2)|0;lo=lo+Math.imul(al7,bl3)|0;mid=mid+Math.imul(al7,bh3)|0;mid=mid+Math.imul(ah7,bl3)|0;hi=hi+Math.imul(ah7,bh3)|0;lo=lo+Math.imul(al6,bl4)|0;mid=mid+Math.imul(al6,bh4)|0;mid=mid+Math.imul(ah6,bl4)|0;hi=hi+Math.imul(ah6,bh4)|0;lo=lo+Math.imul(al5,bl5)|0;mid=mid+Math.imul(al5,bh5)|0;mid=mid+Math.imul(ah5,bl5)|0;hi=hi+Math.imul(ah5,bh5)|0;lo=lo+Math.imul(al4,bl6)|0;mid=mid+Math.imul(al4,bh6)|0;mid=mid+Math.imul(ah4,bl6)|0;hi=hi+Math.imul(ah4,bh6)|0;lo=lo+Math.imul(al3,bl7)|0;mid=mid+Math.imul(al3,bh7)|0;mid=mid+Math.imul(ah3,bl7)|0;hi=hi+Math.imul(ah3,bh7)|0;lo=lo+Math.imul(al2,bl8)|0;mid=mid+Math.imul(al2,bh8)|0;mid=mid+Math.imul(ah2,bl8)|0;hi=hi+Math.imul(ah2,bh8)|0;lo=lo+Math.imul(al1,bl9)|0;mid=mid+Math.imul(al1,bh9)|0;mid=mid+Math.imul(ah1,bl9)|0;hi=hi+Math.imul(ah1,bh9)|0;var w10=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w10>>>26)|0;w10&=67108863;lo=Math.imul(al9,bl2);mid=Math.imul(al9,bh2);mid=mid+Math.imul(ah9,bl2)|0;hi=Math.imul(ah9,bh2);lo=lo+Math.imul(al8,bl3)|0;mid=mid+Math.imul(al8,bh3)|0;mid=mid+Math.imul(ah8,bl3)|0;hi=hi+Math.imul(ah8,bh3)|0;lo=lo+Math.imul(al7,bl4)|0;mid=mid+Math.imul(al7,bh4)|0;mid=mid+Math.imul(ah7,bl4)|0;hi=hi+Math.imul(ah7,bh4)|0;lo=lo+Math.imul(al6,bl5)|0;mid=mid+Math.imul(al6,bh5)|0;mid=mid+Math.imul(ah6,bl5)|0;hi=hi+Math.imul(ah6,bh5)|0;lo=lo+Math.imul(al5,bl6)|0;mid=mid+Math.imul(al5,bh6)|0;mid=mid+Math.imul(ah5,bl6)|0;hi=hi+Math.imul(ah5,bh6)|0;lo=lo+Math.imul(al4,bl7)|0;mid=mid+Math.imul(al4,bh7)|0;mid=mid+Math.imul(ah4,bl7)|0;hi=hi+Math.imul(ah4,bh7)|0;lo=lo+Math.imul(al3,bl8)|0;mid=mid+Math.imul(al3,bh8)|0;mid=mid+Math.imul(ah3,bl8)|0;hi=hi+Math.imul(ah3,bh8)|0;lo=lo+Math.imul(al2,bl9)|0;mid=mid+Math.imul(al2,bh9)|0;mid=mid+Math.imul(ah2,bl9)|0;hi=hi+Math.imul(ah2,bh9)|0;var w11=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w11>>>26)|0;w11&=67108863;lo=Math.imul(al9,bl3);mid=Math.imul(al9,bh3);mid=mid+Math.imul(ah9,bl3)|0;hi=Math.imul(ah9,bh3);lo=lo+Math.imul(al8,bl4)|0;mid=mid+Math.imul(al8,bh4)|0;mid=mid+Math.imul(ah8,bl4)|0;hi=hi+Math.imul(ah8,bh4)|0;lo=lo+Math.imul(al7,bl5)|0;mid=mid+Math.imul(al7,bh5)|0;mid=mid+Math.imul(ah7,bl5)|0;hi=hi+Math.imul(ah7,bh5)|0;lo=lo+Math.imul(al6,bl6)|0;mid=mid+Math.imul(al6,bh6)|0;mid=mid+Math.imul(ah6,bl6)|0;hi=hi+Math.imul(ah6,bh6)|0;lo=lo+Math.imul(al5,bl7)|0;mid=mid+Math.imul(al5,bh7)|0;mid=mid+Math.imul(ah5,bl7)|0;hi=hi+Math.imul(ah5,bh7)|0;lo=lo+Math.imul(al4,bl8)|0;mid=mid+Math.imul(al4,bh8)|0;mid=mid+Math.imul(ah4,bl8)|0;hi=hi+Math.imul(ah4,bh8)|0;lo=lo+Math.imul(al3,bl9)|0;mid=mid+Math.imul(al3,bh9)|0;mid=mid+Math.imul(ah3,bl9)|0;hi=hi+Math.imul(ah3,bh9)|0;var w12=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w12>>>26)|0;w12&=67108863;lo=Math.imul(al9,bl4);mid=Math.imul(al9,bh4);mid=mid+Math.imul(ah9,bl4)|0;hi=Math.imul(ah9,bh4);lo=lo+Math.imul(al8,bl5)|0;mid=mid+Math.imul(al8,bh5)|0;mid=mid+Math.imul(ah8,bl5)|0;hi=hi+Math.imul(ah8,bh5)|0;lo=lo+Math.imul(al7,bl6)|0;mid=mid+Math.imul(al7,bh6)|0;mid=mid+Math.imul(ah7,bl6)|0;hi=hi+Math.imul(ah7,bh6)|0;lo=lo+Math.imul(al6,bl7)|0;mid=mid+Math.imul(al6,bh7)|0;mid=mid+Math.imul(ah6,bl7)|0;hi=hi+Math.imul(ah6,bh7)|0;lo=lo+Math.imul(al5,bl8)|0;mid=mid+Math.imul(al5,bh8)|0;mid=mid+Math.imul(ah5,bl8)|0;hi=hi+Math.imul(ah5,bh8)|0;lo=lo+Math.imul(al4,bl9)|0;mid=mid+Math.imul(al4,bh9)|0;mid=mid+Math.imul(ah4,bl9)|0;hi=hi+Math.imul(ah4,bh9)|0;var w13=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w13>>>26)|0;w13&=67108863;lo=Math.imul(al9,bl5);mid=Math.imul(al9,bh5);mid=mid+Math.imul(ah9,bl5)|0;hi=Math.imul(ah9,bh5);lo=lo+Math.imul(al8,bl6)|0;mid=mid+Math.imul(al8,bh6)|0;mid=mid+Math.imul(ah8,bl6)|0;hi=hi+Math.imul(ah8,bh6)|0;lo=lo+Math.imul(al7,bl7)|0;mid=mid+Math.imul(al7,bh7)|0;mid=mid+Math.imul(ah7,bl7)|0;hi=hi+Math.imul(ah7,bh7)|0;lo=lo+Math.imul(al6,bl8)|0;mid=mid+Math.imul(al6,bh8)|0;mid=mid+Math.imul(ah6,bl8)|0;hi=hi+Math.imul(ah6,bh8)|0;lo=lo+Math.imul(al5,bl9)|0;mid=mid+Math.imul(al5,bh9)|0;mid=mid+Math.imul(ah5,bl9)|0;hi=hi+Math.imul(ah5,bh9)|0;var w14=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w14>>>26)|0;w14&=67108863;lo=Math.imul(al9,bl6);mid=Math.imul(al9,bh6);mid=mid+Math.imul(ah9,bl6)|0;hi=Math.imul(ah9,bh6);lo=lo+Math.imul(al8,bl7)|0;mid=mid+Math.imul(al8,bh7)|0;mid=mid+Math.imul(ah8,bl7)|0;hi=hi+Math.imul(ah8,bh7)|0;lo=lo+Math.imul(al7,bl8)|0;mid=mid+Math.imul(al7,bh8)|0;mid=mid+Math.imul(ah7,bl8)|0;hi=hi+Math.imul(ah7,bh8)|0;lo=lo+Math.imul(al6,bl9)|0;mid=mid+Math.imul(al6,bh9)|0;mid=mid+Math.imul(ah6,bl9)|0;hi=hi+Math.imul(ah6,bh9)|0;var w15=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w15>>>26)|0;w15&=67108863;lo=Math.imul(al9,bl7);mid=Math.imul(al9,bh7);mid=mid+Math.imul(ah9,bl7)|0;hi=Math.imul(ah9,bh7);lo=lo+Math.imul(al8,bl8)|0;mid=mid+Math.imul(al8,bh8)|0;mid=mid+Math.imul(ah8,bl8)|0;hi=hi+Math.imul(ah8,bh8)|0;lo=lo+Math.imul(al7,bl9)|0;mid=mid+Math.imul(al7,bh9)|0;mid=mid+Math.imul(ah7,bl9)|0;hi=hi+Math.imul(ah7,bh9)|0;var w16=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w16>>>26)|0;w16&=67108863;lo=Math.imul(al9,bl8);mid=Math.imul(al9,bh8);mid=mid+Math.imul(ah9,bl8)|0;hi=Math.imul(ah9,bh8);lo=lo+Math.imul(al8,bl9)|0;mid=mid+Math.imul(al8,bh9)|0;mid=mid+Math.imul(ah8,bl9)|0;hi=hi+Math.imul(ah8,bh9)|0;var w17=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w17>>>26)|0;w17&=67108863;lo=Math.imul(al9,bl9);mid=Math.imul(al9,bh9);mid=mid+Math.imul(ah9,bl9)|0;hi=Math.imul(ah9,bh9);var w18=(c+lo|0)+((mid&8191)<<13)|0;c=(hi+(mid>>>13)|0)+(w18>>>26)|0;w18&=67108863;o[0]=w0;o[1]=w1;o[2]=w2;o[3]=w3;o[4]=w4;o[5]=w5;o[6]=w6;o[7]=w7;o[8]=w8;o[9]=w9;o[10]=w10;o[11]=w11;o[12]=w12;o[13]=w13;o[14]=w14;o[15]=w15;o[16]=w16;o[17]=w17;o[18]=w18;if(c!==0){o[19]=c;out.length++}return out};if(!Math.imul){comb10MulTo=smallMulTo}function bigMulTo(self,num,out){out.negative=num.negative^self.negative;out.length=self.length+num.length;var carry=0;var hncarry=0;for(var k=0;k>>26)|0;hncarry+=ncarry>>>26;ncarry&=67108863}out.words[k]=rword;carry=ncarry;ncarry=hncarry}if(carry!==0){out.words[k]=carry}else{out.length--}return out._strip()}function jumboMulTo(self,num,out){return bigMulTo(self,num,out)}BN.prototype.mulTo=function mulTo(num,out){var res;var len=this.length+num.length;if(this.length===10&&num.length===10){res=comb10MulTo(this,num,out)}else if(len<63){res=smallMulTo(this,num,out)}else if(len<1024){res=bigMulTo(this,num,out)}else{res=jumboMulTo(this,num,out)}return res};BN.prototype.mul=function mul(num){var out=new BN(null);out.words=new Array(this.length+num.length);return this.mulTo(num,out)};BN.prototype.mulf=function mulf(num){var out=new BN(null);out.words=new Array(this.length+num.length);return jumboMulTo(this,num,out)};BN.prototype.imul=function imul(num){return this.clone().mulTo(num,this)};BN.prototype.imuln=function imuln(num){var isNegNum=num<0;if(isNegNum)num=-num;assert(typeof num==="number");assert(num<67108864);var carry=0;for(var i=0;i>=26;carry+=w/67108864|0;carry+=lo>>>26;this.words[i]=lo&67108863}if(carry!==0){this.words[i]=carry;this.length++}return isNegNum?this.ineg():this};BN.prototype.muln=function muln(num){return this.clone().imuln(num)};BN.prototype.sqr=function sqr(){return this.mul(this)};BN.prototype.isqr=function isqr(){return this.imul(this.clone())};BN.prototype.pow=function pow(num){var w=toBitArray(num);if(w.length===0)return new BN(1);var res=this;for(var i=0;i=0);var r=bits%26;var s=(bits-r)/26;var carryMask=67108863>>>26-r<<26-r;var i;if(r!==0){var carry=0;for(i=0;i>>26-r}if(carry){this.words[i]=carry;this.length++}}if(s!==0){for(i=this.length-1;i>=0;i--){this.words[i+s]=this.words[i]}for(i=0;i=0);var h;if(hint){h=(hint-hint%26)/26}else{h=0}var r=bits%26;var s=Math.min((bits-r)/26,this.length);var mask=67108863^67108863>>>r<s){this.length-=s;for(i=0;i=0&&(carry!==0||i>=h);i--){var word=this.words[i]|0;this.words[i]=carry<<26-r|word>>>r;carry=word&mask}if(maskedWords&&carry!==0){maskedWords.words[maskedWords.length++]=carry}if(this.length===0){this.words[0]=0;this.length=1}return this._strip()};BN.prototype.ishrn=function ishrn(bits,hint,extended){assert(this.negative===0);return this.iushrn(bits,hint,extended)};BN.prototype.shln=function shln(bits){return this.clone().ishln(bits)};BN.prototype.ushln=function ushln(bits){return this.clone().iushln(bits)};BN.prototype.shrn=function shrn(bits){return this.clone().ishrn(bits)};BN.prototype.ushrn=function ushrn(bits){return this.clone().iushrn(bits)};BN.prototype.testn=function testn(bit){assert(typeof bit==="number"&&bit>=0);var r=bit%26;var s=(bit-r)/26;var q=1<=0);var r=bits%26;var s=(bits-r)/26;assert(this.negative===0,"imaskn works only with positive numbers");if(this.length<=s){return this}if(r!==0){s++}this.length=Math.min(s,this.length);if(r!==0){var mask=67108863^67108863>>>r<=67108864;i++){this.words[i]-=67108864;if(i===this.length-1){this.words[i+1]=1}else{this.words[i+1]++}}this.length=Math.max(this.length,i+1);return this};BN.prototype.isubn=function isubn(num){assert(typeof num==="number");assert(num<67108864);if(num<0)return this.iaddn(-num);if(this.negative!==0){this.negative=0;this.iaddn(num);this.negative=1;return this}this.words[0]-=num;if(this.length===1&&this.words[0]<0){this.words[0]=-this.words[0];this.negative=1}else{for(var i=0;i>26)-(right/67108864|0);this.words[i+shift]=w&67108863}for(;i>26;this.words[i+shift]=w&67108863}if(carry===0)return this._strip();assert(carry===-1);carry=0;for(i=0;i>26;this.words[i]=w&67108863}this.negative=1;return this._strip()};BN.prototype._wordDiv=function _wordDiv(num,mode){var shift=this.length-num.length;var a=this.clone();var b=num;var bhi=b.words[b.length-1]|0;var bhiBits=this._countBits(bhi);shift=26-bhiBits;if(shift!==0){b=b.ushln(shift);a.iushln(shift);bhi=b.words[b.length-1]|0}var m=a.length-b.length;var q;if(mode!=="mod"){q=new BN(null);q.length=m+1;q.words=new Array(q.length);for(var i=0;i=0;j--){var qj=(a.words[b.length+j]|0)*67108864+(a.words[b.length+j-1]|0);qj=Math.min(qj/bhi|0,67108863);a._ishlnsubmul(b,qj,j);while(a.negative!==0){qj--;a.negative=0;a._ishlnsubmul(b,1,j);if(!a.isZero()){a.negative^=1}}if(q){q.words[j]=qj}}if(q){q._strip()}a._strip();if(mode!=="div"&&shift!==0){a.iushrn(shift)}return{div:q||null,mod:a}};BN.prototype.divmod=function divmod(num,mode,positive){assert(!num.isZero());if(this.isZero()){return{div:new BN(0),mod:new BN(0)}}var div,mod,res;if(this.negative!==0&&num.negative===0){res=this.neg().divmod(num,mode);if(mode!=="mod"){div=res.div.neg()}if(mode!=="div"){mod=res.mod.neg();if(positive&&mod.negative!==0){mod.iadd(num)}}return{div:div,mod:mod}}if(this.negative===0&&num.negative!==0){res=this.divmod(num.neg(),mode);if(mode!=="mod"){div=res.div.neg()}return{div:div,mod:res.mod}}if((this.negative&num.negative)!==0){res=this.neg().divmod(num.neg(),mode);if(mode!=="div"){mod=res.mod.neg();if(positive&&mod.negative!==0){mod.isub(num)}}return{div:res.div,mod:mod}}if(num.length>this.length||this.cmp(num)<0){return{div:new BN(0),mod:this}}if(num.length===1){if(mode==="div"){return{div:this.divn(num.words[0]),mod:null}}if(mode==="mod"){return{div:null,mod:new BN(this.modrn(num.words[0]))}}return{div:this.divn(num.words[0]),mod:new BN(this.modrn(num.words[0]))}}return this._wordDiv(num,mode)};BN.prototype.div=function div(num){return this.divmod(num,"div",false).div};BN.prototype.mod=function mod(num){return this.divmod(num,"mod",false).mod};BN.prototype.umod=function umod(num){return this.divmod(num,"mod",true).mod};BN.prototype.divRound=function divRound(num){var dm=this.divmod(num);if(dm.mod.isZero())return dm.div;var mod=dm.div.negative!==0?dm.mod.isub(num):dm.mod;var half=num.ushrn(1);var r2=num.andln(1);var cmp=mod.cmp(half);if(cmp<0||r2===1&&cmp===0)return dm.div;return dm.div.negative!==0?dm.div.isubn(1):dm.div.iaddn(1)};BN.prototype.modrn=function modrn(num){var isNegNum=num<0;if(isNegNum)num=-num;assert(num<=67108863);var p=(1<<26)%num;var acc=0;for(var i=this.length-1;i>=0;i--){acc=(p*acc+(this.words[i]|0))%num}return isNegNum?-acc:acc};BN.prototype.modn=function modn(num){return this.modrn(num)};BN.prototype.idivn=function idivn(num){var isNegNum=num<0;if(isNegNum)num=-num;assert(num<=67108863);var carry=0;for(var i=this.length-1;i>=0;i--){var w=(this.words[i]|0)+carry*67108864;this.words[i]=w/num|0;carry=w%num}this._strip();return isNegNum?this.ineg():this};BN.prototype.divn=function divn(num){return this.clone().idivn(num)};BN.prototype.egcd=function egcd(p){assert(p.negative===0);assert(!p.isZero());var x=this;var y=p.clone();if(x.negative!==0){x=x.umod(p)}else{x=x.clone()}var A=new BN(1);var B=new BN(0);var C=new BN(0);var D=new BN(1);var g=0;while(x.isEven()&&y.isEven()){x.iushrn(1);y.iushrn(1);++g}var yp=y.clone();var xp=x.clone();while(!x.isZero()){for(var i=0,im=1;(x.words[0]&im)===0&&i<26;++i,im<<=1);if(i>0){x.iushrn(i);while(i-- >0){if(A.isOdd()||B.isOdd()){A.iadd(yp);B.isub(xp)}A.iushrn(1);B.iushrn(1)}}for(var j=0,jm=1;(y.words[0]&jm)===0&&j<26;++j,jm<<=1);if(j>0){y.iushrn(j);while(j-- >0){if(C.isOdd()||D.isOdd()){C.iadd(yp);D.isub(xp)}C.iushrn(1);D.iushrn(1)}}if(x.cmp(y)>=0){x.isub(y);A.isub(C);B.isub(D)}else{y.isub(x);C.isub(A);D.isub(B)}}return{a:C,b:D,gcd:y.iushln(g)}};BN.prototype._invmp=function _invmp(p){assert(p.negative===0);assert(!p.isZero());var a=this;var b=p.clone();if(a.negative!==0){a=a.umod(p)}else{a=a.clone()}var x1=new BN(1);var x2=new BN(0);var delta=b.clone();while(a.cmpn(1)>0&&b.cmpn(1)>0){for(var i=0,im=1;(a.words[0]&im)===0&&i<26;++i,im<<=1);if(i>0){a.iushrn(i);while(i-- >0){if(x1.isOdd()){x1.iadd(delta)}x1.iushrn(1)}}for(var j=0,jm=1;(b.words[0]&jm)===0&&j<26;++j,jm<<=1);if(j>0){b.iushrn(j);while(j-- >0){if(x2.isOdd()){x2.iadd(delta)}x2.iushrn(1)}}if(a.cmp(b)>=0){a.isub(b);x1.isub(x2)}else{b.isub(a);x2.isub(x1)}}var res;if(a.cmpn(1)===0){res=x1}else{res=x2}if(res.cmpn(0)<0){res.iadd(p)}return res};BN.prototype.gcd=function gcd(num){if(this.isZero())return num.abs();if(num.isZero())return this.abs();var a=this.clone();var b=num.clone();a.negative=0;b.negative=0;for(var shift=0;a.isEven()&&b.isEven();shift++){a.iushrn(1);b.iushrn(1)}do{while(a.isEven()){a.iushrn(1)}while(b.isEven()){b.iushrn(1)}var r=a.cmp(b);if(r<0){var t=a;a=b;b=t}else if(r===0||b.cmpn(1)===0){break}a.isub(b)}while(true);return b.iushln(shift)};BN.prototype.invm=function invm(num){return this.egcd(num).a.umod(num)};BN.prototype.isEven=function isEven(){return(this.words[0]&1)===0};BN.prototype.isOdd=function isOdd(){return(this.words[0]&1)===1};BN.prototype.andln=function andln(num){return this.words[0]&num};BN.prototype.bincn=function bincn(bit){assert(typeof bit==="number");var r=bit%26;var s=(bit-r)/26;var q=1<>>26;w&=67108863;this.words[i]=w}if(carry!==0){this.words[i]=carry;this.length++}return this};BN.prototype.isZero=function isZero(){return this.length===1&&this.words[0]===0};BN.prototype.cmpn=function cmpn(num){var negative=num<0;if(this.negative!==0&&!negative)return-1;if(this.negative===0&&negative)return 1;this._strip();var res;if(this.length>1){res=1}else{if(negative){num=-num}assert(num<=67108863,"Number is too big");var w=this.words[0]|0;res=w===num?0:wnum.length)return 1;if(this.length=0;i--){var a=this.words[i]|0;var b=num.words[i]|0;if(a===b)continue;if(ab){res=1}break}return res};BN.prototype.gtn=function gtn(num){return this.cmpn(num)===1};BN.prototype.gt=function gt(num){return this.cmp(num)===1};BN.prototype.gten=function gten(num){return this.cmpn(num)>=0};BN.prototype.gte=function gte(num){return this.cmp(num)>=0};BN.prototype.ltn=function ltn(num){return this.cmpn(num)===-1};BN.prototype.lt=function lt(num){return this.cmp(num)===-1};BN.prototype.lten=function lten(num){return this.cmpn(num)<=0};BN.prototype.lte=function lte(num){return this.cmp(num)<=0};BN.prototype.eqn=function eqn(num){return this.cmpn(num)===0};BN.prototype.eq=function eq(num){return this.cmp(num)===0};BN.red=function red(num){return new Red(num)};BN.prototype.toRed=function toRed(ctx){assert(!this.red,"Already a number in reduction context");assert(this.negative===0,"red works only with positives");return ctx.convertTo(this)._forceRed(ctx)};BN.prototype.fromRed=function fromRed(){assert(this.red,"fromRed works only with numbers in reduction context");return this.red.convertFrom(this)};BN.prototype._forceRed=function _forceRed(ctx){this.red=ctx;return this};BN.prototype.forceRed=function forceRed(ctx){assert(!this.red,"Already a number in reduction context");return this._forceRed(ctx)};BN.prototype.redAdd=function redAdd(num){assert(this.red,"redAdd works only with red numbers");return this.red.add(this,num)};BN.prototype.redIAdd=function redIAdd(num){assert(this.red,"redIAdd works only with red numbers");return this.red.iadd(this,num)};BN.prototype.redSub=function redSub(num){assert(this.red,"redSub works only with red numbers");return this.red.sub(this,num)};BN.prototype.redISub=function redISub(num){assert(this.red,"redISub works only with red numbers");return this.red.isub(this,num)};BN.prototype.redShl=function redShl(num){assert(this.red,"redShl works only with red numbers");return this.red.shl(this,num)};BN.prototype.redMul=function redMul(num){assert(this.red,"redMul works only with red numbers");this.red._verify2(this,num);return this.red.mul(this,num)};BN.prototype.redIMul=function redIMul(num){assert(this.red,"redMul works only with red numbers");this.red._verify2(this,num);return this.red.imul(this,num)};BN.prototype.redSqr=function redSqr(){assert(this.red,"redSqr works only with red numbers");this.red._verify1(this);return this.red.sqr(this)};BN.prototype.redISqr=function redISqr(){assert(this.red,"redISqr works only with red numbers");this.red._verify1(this);return this.red.isqr(this)};BN.prototype.redSqrt=function redSqrt(){assert(this.red,"redSqrt works only with red numbers");this.red._verify1(this);return this.red.sqrt(this)};BN.prototype.redInvm=function redInvm(){assert(this.red,"redInvm works only with red numbers");this.red._verify1(this);return this.red.invm(this)};BN.prototype.redNeg=function redNeg(){assert(this.red,"redNeg works only with red numbers");this.red._verify1(this);return this.red.neg(this)};BN.prototype.redPow=function redPow(num){assert(this.red&&!num.red,"redPow(normalNum)");this.red._verify1(this);return this.red.pow(this,num)};var primes={k256:null,p224:null,p192:null,p25519:null};function MPrime(name,p){this.name=name;this.p=new BN(p,16);this.n=this.p.bitLength();this.k=new BN(1).iushln(this.n).isub(this.p);this.tmp=this._tmp()}MPrime.prototype._tmp=function _tmp(){var tmp=new BN(null);tmp.words=new Array(Math.ceil(this.n/13));return tmp};MPrime.prototype.ireduce=function ireduce(num){var r=num;var rlen;do{this.split(r,this.tmp);r=this.imulK(r);r=r.iadd(this.tmp);rlen=r.bitLength()}while(rlen>this.n);var cmp=rlen0){r.isub(this.p)}else{if(r.strip!==undefined){r.strip()}else{r._strip()}}return r};MPrime.prototype.split=function split(input,out){input.iushrn(this.n,0,out)};MPrime.prototype.imulK=function imulK(num){return num.imul(this.k)};function K256(){MPrime.call(this,"k256","ffffffff ffffffff ffffffff ffffffff ffffffff ffffffff fffffffe fffffc2f")}inherits(K256,MPrime);K256.prototype.split=function split(input,output){var mask=4194303;var outLen=Math.min(input.length,9);for(var i=0;i>>22;prev=next}prev>>>=22;input.words[i-10]=prev;if(prev===0&&input.length>10){input.length-=10}else{input.length-=9}};K256.prototype.imulK=function imulK(num){num.words[num.length]=0;num.words[num.length+1]=0;num.length+=2;var lo=0;for(var i=0;i>>=26;num.words[i]=lo;carry=hi}if(carry!==0){num.words[num.length++]=carry}return num};BN._prime=function prime(name){if(primes[name])return primes[name];var prime;if(name==="k256"){prime=new K256}else if(name==="p224"){prime=new P224}else if(name==="p192"){prime=new P192}else if(name==="p25519"){prime=new P25519}else{throw new Error("Unknown prime "+name)}primes[name]=prime;return prime};function Red(m){if(typeof m==="string"){var prime=BN._prime(m);this.m=prime.p;this.prime=prime}else{assert(m.gtn(1),"modulus must be greater than 1");this.m=m;this.prime=null}}Red.prototype._verify1=function _verify1(a){assert(a.negative===0,"red works only with positives");assert(a.red,"red works only with red numbers")};Red.prototype._verify2=function _verify2(a,b){assert((a.negative|b.negative)===0,"red works only with positives");assert(a.red&&a.red===b.red,"red works only with red numbers")};Red.prototype.imod=function imod(a){if(this.prime)return this.prime.ireduce(a)._forceRed(this);move(a,a.umod(this.m)._forceRed(this));return a};Red.prototype.neg=function neg(a){if(a.isZero()){return a.clone()}return this.m.sub(a)._forceRed(this)};Red.prototype.add=function add(a,b){this._verify2(a,b);var res=a.add(b);if(res.cmp(this.m)>=0){res.isub(this.m)}return res._forceRed(this)};Red.prototype.iadd=function iadd(a,b){this._verify2(a,b);var res=a.iadd(b);if(res.cmp(this.m)>=0){res.isub(this.m)}return res};Red.prototype.sub=function sub(a,b){this._verify2(a,b);var res=a.sub(b);if(res.cmpn(0)<0){res.iadd(this.m)}return res._forceRed(this)};Red.prototype.isub=function isub(a,b){this._verify2(a,b);var res=a.isub(b);if(res.cmpn(0)<0){res.iadd(this.m)}return res};Red.prototype.shl=function shl(a,num){this._verify1(a);return this.imod(a.ushln(num))};Red.prototype.imul=function imul(a,b){this._verify2(a,b);return this.imod(a.imul(b))};Red.prototype.mul=function mul(a,b){this._verify2(a,b);return this.imod(a.mul(b))};Red.prototype.isqr=function isqr(a){return this.imul(a,a.clone())};Red.prototype.sqr=function sqr(a){return this.mul(a,a)};Red.prototype.sqrt=function sqrt(a){if(a.isZero())return a.clone();var mod3=this.m.andln(3);assert(mod3%2===1);if(mod3===3){var pow=this.m.add(new BN(1)).iushrn(2);return this.pow(a,pow)}var q=this.m.subn(1);var s=0;while(!q.isZero()&&q.andln(1)===0){s++;q.iushrn(1)}assert(!q.isZero());var one=new BN(1).toRed(this);var nOne=one.redNeg();var lpow=this.m.subn(1).iushrn(1);var z=this.m.bitLength();z=new BN(2*z*z).toRed(this);while(this.pow(z,lpow).cmp(nOne)!==0){z.redIAdd(nOne)}var c=this.pow(z,q);var r=this.pow(a,q.addn(1).iushrn(1));var t=this.pow(a,q);var m=s;while(t.cmp(one)!==0){var tmp=t;for(var i=0;tmp.cmp(one)!==0;i++){tmp=tmp.redSqr()}assert(i=0;i--){var word=num.words[i];for(var j=start-1;j>=0;j--){var bit=word>>j&1;if(res!==wnd[0]){res=this.sqr(res)}if(bit===0&¤t===0){currentLen=0;continue}current<<=1;current|=bit;currentLen++;if(currentLen!==windowSize&&(i!==0||j!==0))continue;res=this.mul(res,wnd[current]);currentLen=0;current=0}start=26}return res};Red.prototype.convertTo=function convertTo(num){var r=num.umod(this.m);return r===num?r.clone():r};Red.prototype.convertFrom=function convertFrom(num){var res=num.clone();res.red=null;return res};BN.mont=function mont(num){return new Mont(num)};function Mont(m){Red.call(this,m);this.shift=this.m.bitLength();if(this.shift%26!==0){this.shift+=26-this.shift%26}this.r=new BN(1).iushln(this.shift);this.r2=this.imod(this.r.sqr());this.rinv=this.r._invmp(this.m);this.minv=this.rinv.mul(this.r).isubn(1).div(this.m);this.minv=this.minv.umod(this.r);this.minv=this.r.sub(this.minv)}inherits(Mont,Red);Mont.prototype.convertTo=function convertTo(num){return this.imod(num.ushln(this.shift))};Mont.prototype.convertFrom=function convertFrom(num){var r=this.imod(num.mul(this.rinv));r.red=null;return r};Mont.prototype.imul=function imul(a,b){if(a.isZero()||b.isZero()){a.words[0]=0;a.length=1;return a}var t=a.imul(b);var c=t.maskn(this.shift).mul(this.minv).imaskn(this.shift).mul(this.m);var u=t.isub(c).iushrn(this.shift);var res=u;if(u.cmp(this.m)>=0){res=u.isub(this.m)}else if(u.cmpn(0)<0){res=u.iadd(this.m)}return res._forceRed(this)};Mont.prototype.mul=function mul(a,b){if(a.isZero()||b.isZero())return new BN(0)._forceRed(this);var t=a.mul(b);var c=t.maskn(this.shift).mul(this.minv).imaskn(this.shift).mul(this.m);var u=t.isub(c).iushrn(this.shift);var res=u;if(u.cmp(this.m)>=0){res=u.isub(this.m)}else if(u.cmpn(0)<0){res=u.iadd(this.m)}return res._forceRed(this)};Mont.prototype.invm=function invm(a){var res=this.imod(a._invmp(this.m).mul(this.r2));return res._forceRed(this)}})(module,bn)})(bn$1);return bn$1.exports}var bnExports=requireBn();var BN=getDefaultExportFromCjs(bnExports);var safeBuffer={exports:{}}; +/*! safe-buffer. MIT License. Feross Aboukhadijeh */var hasRequiredSafeBuffer;function requireSafeBuffer(){if(hasRequiredSafeBuffer)return safeBuffer.exports;hasRequiredSafeBuffer=1;(function(module,exports){var buffer=requireBuffer();var Buffer=buffer.Buffer;function copyProps(src,dst){for(var key in src){dst[key]=src[key]}}if(Buffer.from&&Buffer.alloc&&Buffer.allocUnsafe&&Buffer.allocUnsafeSlow){module.exports=buffer}else{copyProps(buffer,exports);exports.Buffer=SafeBuffer}function SafeBuffer(arg,encodingOrOffset,length){return Buffer(arg,encodingOrOffset,length)}SafeBuffer.prototype=Object.create(Buffer.prototype);copyProps(Buffer,SafeBuffer);SafeBuffer.from=function(arg,encodingOrOffset,length){if(typeof arg==="number"){throw new TypeError("Argument must not be a number")}return Buffer(arg,encodingOrOffset,length)};SafeBuffer.alloc=function(size,fill,encoding){if(typeof size!=="number"){throw new TypeError("Argument must be a number")}var buf=Buffer(size);if(fill!==undefined){if(typeof encoding==="string"){buf.fill(fill,encoding)}else{buf.fill(fill)}}else{buf.fill(0)}return buf};SafeBuffer.allocUnsafe=function(size){if(typeof size!=="number"){throw new TypeError("Argument must be a number")}return Buffer(size)};SafeBuffer.allocUnsafeSlow=function(size){if(typeof size!=="number"){throw new TypeError("Argument must be a number")}return buffer.SlowBuffer(size)}})(safeBuffer,safeBuffer.exports);return safeBuffer.exports}var src;var hasRequiredSrc;function requireSrc(){if(hasRequiredSrc)return src;hasRequiredSrc=1;var _Buffer=requireSafeBuffer().Buffer;function base(ALPHABET){if(ALPHABET.length>=255){throw new TypeError("Alphabet too long")}var BASE_MAP=new Uint8Array(256);for(var j=0;j>>0;var b58=new Uint8Array(size);while(pbegin!==pend){var carry=source[pbegin];var i=0;for(var it1=size-1;(carry!==0||i>>0;b58[it1]=carry%BASE>>>0;carry=carry/BASE>>>0}if(carry!==0){throw new Error("Non-zero carry")}length=i;pbegin++}var it2=size-length;while(it2!==size&&b58[it2]===0){it2++}var str=LEADER.repeat(zeroes);for(;it2>>0;var b256=new Uint8Array(size);while(psz255){return}var carry=BASE_MAP[charCode];if(carry===255){return}var i=0;for(var it3=size-1;(carry!==0||i>>0;b256[it3]=carry%256>>>0;carry=carry/256>>>0}if(carry!==0){throw new Error("Non-zero carry")}length=i;psz++}var it4=size-length;while(it4!==size&&b256[it4]===0){it4++}var vch=_Buffer.allocUnsafe(zeroes+(size-it4));vch.fill(0,0,zeroes);var j=zeroes;while(it4!==size){vch[j++]=b256[it4++]}return vch}function decode(string){var buffer=decodeUnsafe(string);if(buffer){return buffer}throw new Error("Non-base"+BASE+" character")}return{encode:encode,decodeUnsafe:decodeUnsafe,decode:decode}}src=base;return src}var bs58$1;var hasRequiredBs58;function requireBs58(){if(hasRequiredBs58)return bs58$1;hasRequiredBs58=1;var basex=requireSrc();var ALPHABET="123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";bs58$1=basex(ALPHABET);return bs58$1}var bs58Exports=requireBs58();var bs58=getDefaultExportFromCjs(bs58Exports);function anumber(n){if(!Number.isSafeInteger(n)||n<0)throw new Error("positive integer expected, got "+n)}function isBytes(a){return a instanceof Uint8Array||ArrayBuffer.isView(a)&&a.constructor.name==="Uint8Array"}function abytes(b,...lengths){if(!isBytes(b))throw new Error("Uint8Array expected");if(lengths.length>0&&!lengths.includes(b.length))throw new Error("Uint8Array expected of length "+lengths+", got length="+b.length)}function aexists(instance,checkFinished=true){if(instance.destroyed)throw new Error("Hash instance has been destroyed");if(checkFinished&&instance.finished)throw new Error("Hash#digest() has already been called")}function aoutput(out,instance){abytes(out);const min=instance.outputLen;if(out.lengthnew Uint32Array(arr.buffer,arr.byteOffset,Math.floor(arr.byteLength/4));const createView=arr=>new DataView(arr.buffer,arr.byteOffset,arr.byteLength);const rotr=(word,shift)=>word<<32-shift|word>>>shift;const isLE=(()=>new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68)();const byteSwap=word=>word<<24&4278190080|word<<8&16711680|word>>>8&65280|word>>>24&255;function byteSwap32(arr){for(let i=0;ihashCons().update(toBytes(msg)).digest();const tmp=hashCons();hashC.outputLen=tmp.outputLen;hashC.blockLen=tmp.blockLen;hashC.create=()=>hashCons();return hashC}function setBigUint64(view,byteOffset,value,isLE){if(typeof view.setBigUint64==="function")return view.setBigUint64(byteOffset,value,isLE);const _32n=BigInt(32);const _u32_max=BigInt(4294967295);const wh=Number(value>>_32n&_u32_max);const wl=Number(value&_u32_max);const h=isLE?4:0;const l=isLE?0:4;view.setUint32(byteOffset+h,wh,isLE);view.setUint32(byteOffset+l,wl,isLE)}const Chi=(a,b,c)=>a&b^~a&c;const Maj=(a,b,c)=>a&b^a&c^b&c;class HashMD extends Hash{constructor(blockLen,outputLen,padOffset,isLE){super();this.blockLen=blockLen;this.outputLen=outputLen;this.padOffset=padOffset;this.isLE=isLE;this.finished=false;this.length=0;this.pos=0;this.destroyed=false;this.buffer=new Uint8Array(blockLen);this.view=createView(this.buffer)}update(data){aexists(this);const{view:view,buffer:buffer,blockLen:blockLen}=this;data=toBytes(data);const len=data.length;for(let pos=0;posblockLen-pos){this.process(view,0);pos=0}for(let i=pos;istate.length)throw new Error("_sha2: outputLen bigger than state");for(let i=0;i>>3;const s1=rotr(W2,17)^rotr(W2,19)^W2>>>10;SHA256_W$1[i]=s1+SHA256_W$1[i-7]+s0+SHA256_W$1[i-16]|0}let{A:A,B:B,C:C,D:D,E:E,F:F,G:G,H:H}=this;for(let i=0;i<64;i++){const sigma1=rotr(E,6)^rotr(E,11)^rotr(E,25);const T1=H+sigma1+Chi(E,F,G)+SHA256_K$1[i]+SHA256_W$1[i]|0;const sigma0=rotr(A,2)^rotr(A,13)^rotr(A,22);const T2=sigma0+Maj(A,B,C)|0;H=G;G=F;F=E;E=D+T1|0;D=C;C=B;B=A;A=T1+T2|0}A=A+this.A|0;B=B+this.B|0;C=C+this.C|0;D=D+this.D|0;E=E+this.E|0;F=F+this.F|0;G=G+this.G|0;H=H+this.H|0;this.set(A,B,C,D,E,F,G,H)}roundClean(){SHA256_W$1.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0);this.buffer.fill(0)}};const sha256$1=wrapConstructor((()=>new SHA256$1));var lib={};var encoding_lib={};var hasRequiredEncoding_lib;function requireEncoding_lib(){if(hasRequiredEncoding_lib)return encoding_lib;hasRequiredEncoding_lib=1;function inRange(a,min,max){return min<=a&&a<=max}function ToDictionary(o){if(o===undefined)return{};if(o===Object(o))return o;throw TypeError("Could not convert argument to dictionary")}function stringToCodePoints(string){var s=String(string);var n=s.length;var i=0;var u=[];while(i57343){u.push(c)}else if(56320<=c&&c<=57343){u.push(65533)}else if(55296<=c&&c<=56319){if(i===n-1){u.push(65533)}else{var d=string.charCodeAt(i+1);if(56320<=d&&d<=57343){var a=c&1023;var b=d&1023;u.push(65536+(a<<10)+b);i+=1}else{u.push(65533)}}}i+=1}return u}function codePointsToString(code_points){var s="";for(var i=0;i>10)+55296,(cp&1023)+56320)}}return s}var end_of_stream=-1;function Stream(tokens){this.tokens=[].slice.call(tokens)}Stream.prototype={endOfStream:function(){return!this.tokens.length},read:function(){if(!this.tokens.length)return end_of_stream;return this.tokens.shift()},prepend:function(token){if(Array.isArray(token)){var tokens=token;while(tokens.length)this.tokens.unshift(tokens.pop())}else{this.tokens.unshift(token)}},push:function(token){if(Array.isArray(token)){var tokens=token;while(tokens.length)this.tokens.push(tokens.shift())}else{this.tokens.push(token)}}};var finished=-1;function decoderError(fatal,opt_code_point){if(fatal)throw TypeError("Decoder error");return opt_code_point||65533}var DEFAULT_ENCODING="utf-8";function TextDecoder(encoding,options){if(!(this instanceof TextDecoder)){return new TextDecoder(encoding,options)}encoding=encoding!==undefined?String(encoding).toLowerCase():DEFAULT_ENCODING;if(encoding!==DEFAULT_ENCODING){throw new Error("Encoding not supported. Only utf-8 is supported")}options=ToDictionary(options);this._streaming=false;this._BOMseen=false;this._decoder=null;this._fatal=Boolean(options["fatal"]);this._ignoreBOM=Boolean(options["ignoreBOM"]);Object.defineProperty(this,"encoding",{value:"utf-8"});Object.defineProperty(this,"fatal",{value:this._fatal});Object.defineProperty(this,"ignoreBOM",{value:this._ignoreBOM})}TextDecoder.prototype={decode:function decode(input,options){var bytes;if(typeof input==="object"&&input instanceof ArrayBuffer){bytes=new Uint8Array(input)}else if(typeof input==="object"&&"buffer"in input&&input.buffer instanceof ArrayBuffer){bytes=new Uint8Array(input.buffer,input.byteOffset,input.byteLength)}else{bytes=new Uint8Array(0)}options=ToDictionary(options);if(!this._streaming){this._decoder=new UTF8Decoder({fatal:this._fatal});this._BOMseen=false}this._streaming=Boolean(options["stream"]);var input_stream=new Stream(bytes);var code_points=[];var result;while(!input_stream.endOfStream()){result=this._decoder.handler(input_stream,input_stream.read());if(result===finished)break;if(result===null)continue;if(Array.isArray(result))code_points.push.apply(code_points,result);else code_points.push(result)}if(!this._streaming){do{result=this._decoder.handler(input_stream,input_stream.read());if(result===finished)break;if(result===null)continue;if(Array.isArray(result))code_points.push.apply(code_points,result);else code_points.push(result)}while(!input_stream.endOfStream());this._decoder=null}if(code_points.length){if(["utf-8"].indexOf(this.encoding)!==-1&&!this._ignoreBOM&&!this._BOMseen){if(code_points[0]===65279){this._BOMseen=true;code_points.shift()}else{this._BOMseen=true}}}return codePointsToString(code_points)}};function TextEncoder(encoding,options){if(!(this instanceof TextEncoder))return new TextEncoder(encoding,options);encoding=encoding!==undefined?String(encoding).toLowerCase():DEFAULT_ENCODING;if(encoding!==DEFAULT_ENCODING){throw new Error("Encoding not supported. Only utf-8 is supported")}options=ToDictionary(options);this._streaming=false;this._encoder=null;this._options={fatal:Boolean(options["fatal"])};Object.defineProperty(this,"encoding",{value:"utf-8"})}TextEncoder.prototype={encode:function encode(opt_string,options){opt_string=opt_string?String(opt_string):"";options=ToDictionary(options);if(!this._streaming)this._encoder=new UTF8Encoder(this._options);this._streaming=Boolean(options["stream"]);var bytes=[];var input_stream=new Stream(stringToCodePoints(opt_string));var result;while(!input_stream.endOfStream()){result=this._encoder.handler(input_stream,input_stream.read());if(result===finished)break;if(Array.isArray(result))bytes.push.apply(bytes,result);else bytes.push(result)}if(!this._streaming){while(true){result=this._encoder.handler(input_stream,input_stream.read());if(result===finished)break;if(Array.isArray(result))bytes.push.apply(bytes,result);else bytes.push(result)}this._encoder=null}return new Uint8Array(bytes)}};function UTF8Decoder(options){var fatal=options.fatal;var utf8_code_point=0,utf8_bytes_seen=0,utf8_bytes_needed=0,utf8_lower_boundary=128,utf8_upper_boundary=191;this.handler=function(stream,bite){if(bite===end_of_stream&&utf8_bytes_needed!==0){utf8_bytes_needed=0;return decoderError(fatal)}if(bite===end_of_stream)return finished;if(utf8_bytes_needed===0){if(inRange(bite,0,127)){return bite}if(inRange(bite,194,223)){utf8_bytes_needed=1;utf8_code_point=bite-192}else if(inRange(bite,224,239)){if(bite===224)utf8_lower_boundary=160;if(bite===237)utf8_upper_boundary=159;utf8_bytes_needed=2;utf8_code_point=bite-224}else if(inRange(bite,240,244)){if(bite===240)utf8_lower_boundary=144;if(bite===244)utf8_upper_boundary=143;utf8_bytes_needed=3;utf8_code_point=bite-240}else{return decoderError(fatal)}utf8_code_point=utf8_code_point<<6*utf8_bytes_needed;return null}if(!inRange(bite,utf8_lower_boundary,utf8_upper_boundary)){utf8_code_point=utf8_bytes_needed=utf8_bytes_seen=0;utf8_lower_boundary=128;utf8_upper_boundary=191;stream.prepend(bite);return decoderError(fatal)}utf8_lower_boundary=128;utf8_upper_boundary=191;utf8_bytes_seen+=1;utf8_code_point+=bite-128<<6*(utf8_bytes_needed-utf8_bytes_seen);if(utf8_bytes_seen!==utf8_bytes_needed)return null;var code_point=utf8_code_point;utf8_code_point=utf8_bytes_needed=utf8_bytes_seen=0;return code_point}}function UTF8Encoder(options){options.fatal;this.handler=function(stream,code_point){if(code_point===end_of_stream)return finished;if(inRange(code_point,0,127))return code_point;var count,offset;if(inRange(code_point,128,2047)){count=1;offset=192}else if(inRange(code_point,2048,65535)){count=2;offset=224}else if(inRange(code_point,65536,1114111)){count=3;offset=240}var bytes=[(code_point>>6*count)+offset];while(count>0){var temp=code_point>>6*(count-1);bytes.push(128|temp&63);count-=1}return bytes}}encoding_lib.TextEncoder=TextEncoder;encoding_lib.TextDecoder=TextDecoder;return encoding_lib}var hasRequiredLib;function requireLib(){if(hasRequiredLib)return lib;hasRequiredLib=1;var __createBinding=lib&&lib.__createBinding||(Object.create?function(o,m,k,k2){if(k2===undefined)k2=k;Object.defineProperty(o,k2,{enumerable:true,get:function(){return m[k]}})}:function(o,m,k,k2){if(k2===undefined)k2=k;o[k2]=m[k]});var __setModuleDefault=lib&&lib.__setModuleDefault||(Object.create?function(o,v){Object.defineProperty(o,"default",{enumerable:true,value:v})}:function(o,v){o["default"]=v});var __decorate=lib&&lib.__decorate||function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};var __importStar=lib&&lib.__importStar||function(mod){if(mod&&mod.__esModule)return mod;var result={};if(mod!=null)for(var k in mod)if(k!=="default"&&Object.hasOwnProperty.call(mod,k))__createBinding(result,mod,k);__setModuleDefault(result,mod);return result};var __importDefault=lib&&lib.__importDefault||function(mod){return mod&&mod.__esModule?mod:{default:mod}};Object.defineProperty(lib,"__esModule",{value:true});lib.deserializeUnchecked=lib.deserialize=lib.serialize=lib.BinaryReader=lib.BinaryWriter=lib.BorshError=lib.baseDecode=lib.baseEncode=void 0;const bn_js_1=__importDefault(requireBn());const bs58_1=__importDefault(requireBs58());const encoding=__importStar(requireEncoding_lib());const ResolvedTextDecoder=typeof TextDecoder!=="function"?encoding.TextDecoder:TextDecoder;const textDecoder=new ResolvedTextDecoder("utf-8",{fatal:true});function baseEncode(value){if(typeof value==="string"){value=Buffer.from(value,"utf8")}return bs58_1.default.encode(Buffer.from(value))}lib.baseEncode=baseEncode;function baseDecode(value){return Buffer.from(bs58_1.default.decode(value))}lib.baseDecode=baseDecode;const INITIAL_LENGTH=1024;class BorshError extends Error{constructor(message){super(message);this.fieldPath=[];this.originalMessage=message}addToFieldPath(fieldName){this.fieldPath.splice(0,0,fieldName);this.message=this.originalMessage+": "+this.fieldPath.join(".")}}lib.BorshError=BorshError;class BinaryWriter{constructor(){this.buf=Buffer.alloc(INITIAL_LENGTH);this.length=0}maybeResize(){if(this.buf.length<16+this.length){this.buf=Buffer.concat([this.buf,Buffer.alloc(INITIAL_LENGTH)])}}writeU8(value){this.maybeResize();this.buf.writeUInt8(value,this.length);this.length+=1}writeU16(value){this.maybeResize();this.buf.writeUInt16LE(value,this.length);this.length+=2}writeU32(value){this.maybeResize();this.buf.writeUInt32LE(value,this.length);this.length+=4}writeU64(value){this.maybeResize();this.writeBuffer(Buffer.from(new bn_js_1.default(value).toArray("le",8)))}writeU128(value){this.maybeResize();this.writeBuffer(Buffer.from(new bn_js_1.default(value).toArray("le",16)))}writeU256(value){this.maybeResize();this.writeBuffer(Buffer.from(new bn_js_1.default(value).toArray("le",32)))}writeU512(value){this.maybeResize();this.writeBuffer(Buffer.from(new bn_js_1.default(value).toArray("le",64)))}writeBuffer(buffer){this.buf=Buffer.concat([Buffer.from(this.buf.subarray(0,this.length)),buffer,Buffer.alloc(INITIAL_LENGTH)]);this.length+=buffer.length}writeString(str){this.maybeResize();const b=Buffer.from(str,"utf8");this.writeU32(b.length);this.writeBuffer(b)}writeFixedArray(array){this.writeBuffer(Buffer.from(array))}writeArray(array,fn){this.maybeResize();this.writeU32(array.length);for(const elem of array){this.maybeResize();fn(elem)}}toArray(){return this.buf.subarray(0,this.length)}}lib.BinaryWriter=BinaryWriter;function handlingRangeError(target,propertyKey,propertyDescriptor){const originalMethod=propertyDescriptor.value;propertyDescriptor.value=function(...args){try{return originalMethod.apply(this,args)}catch(e){if(e instanceof RangeError){const code=e.code;if(["ERR_BUFFER_OUT_OF_BOUNDS","ERR_OUT_OF_RANGE"].indexOf(code)>=0){throw new BorshError("Reached the end of buffer when deserializing")}}throw e}}}class BinaryReader{constructor(buf){this.buf=buf;this.offset=0}readU8(){const value=this.buf.readUInt8(this.offset);this.offset+=1;return value}readU16(){const value=this.buf.readUInt16LE(this.offset);this.offset+=2;return value}readU32(){const value=this.buf.readUInt32LE(this.offset);this.offset+=4;return value}readU64(){const buf=this.readBuffer(8);return new bn_js_1.default(buf,"le")}readU128(){const buf=this.readBuffer(16);return new bn_js_1.default(buf,"le")}readU256(){const buf=this.readBuffer(32);return new bn_js_1.default(buf,"le")}readU512(){const buf=this.readBuffer(64);return new bn_js_1.default(buf,"le")}readBuffer(len){if(this.offset+len>this.buf.length){throw new BorshError(`Expected buffer length ${len} isn't within bounds`)}const result=this.buf.slice(this.offset,this.offset+len);this.offset+=len;return result}readString(){const len=this.readU32();const buf=this.readBuffer(len);try{return textDecoder.decode(buf)}catch(e){throw new BorshError(`Error decoding UTF-8 string: ${e}`)}}readFixedArray(len){return new Uint8Array(this.readBuffer(len))}readArray(fn){const len=this.readU32();const result=Array();for(let i=0;i{serializeField(schema,fieldName,item,fieldType[0],writer)}))}}else if(fieldType.kind!==undefined){switch(fieldType.kind){case"option":{if(value===null||value===undefined){writer.writeU8(0)}else{writer.writeU8(1);serializeField(schema,fieldName,value,fieldType.type,writer)}break}case"map":{writer.writeU32(value.size);value.forEach(((val,key)=>{serializeField(schema,fieldName,key,fieldType.key,writer);serializeField(schema,fieldName,val,fieldType.value,writer)}));break}default:throw new BorshError(`FieldType ${fieldType} unrecognized`)}}else{serializeStruct(schema,value,writer)}}catch(error){if(error instanceof BorshError){error.addToFieldPath(fieldName)}throw error}}function serializeStruct(schema,obj,writer){if(typeof obj.borshSerialize==="function"){obj.borshSerialize(writer);return}const structSchema=schema.get(obj.constructor);if(!structSchema){throw new BorshError(`Class ${obj.constructor.name} is missing in schema`)}if(structSchema.kind==="struct"){structSchema.fields.map((([fieldName,fieldType])=>{serializeField(schema,fieldName,obj[fieldName],fieldType,writer)}))}else if(structSchema.kind==="enum"){const name=obj[structSchema.field];for(let idx=0;idxdeserializeField(schema,fieldName,fieldType[0],reader)))}}if(fieldType.kind==="option"){const option=reader.readU8();if(option){return deserializeField(schema,fieldName,fieldType.type,reader)}return undefined}if(fieldType.kind==="map"){let map=new Map;const length=reader.readU32();for(let i=0;i=structSchema.values.length){throw new BorshError(`Enum index: ${idx} is out of range`)}const[fieldName,fieldType]=structSchema.values[idx];const fieldValue=deserializeField(schema,fieldName,fieldType,reader);return new classType({[fieldName]:fieldValue})}throw new BorshError(`Unexpected schema kind: ${structSchema.kind} for ${classType.constructor.name}`)}function deserialize(schema,classType,buffer,Reader=BinaryReader){const reader=new Reader(buffer);const result=deserializeStruct(schema,classType,reader);if(reader.offset{this.enum=key}))}}const SOLANA_SCHEMA=new Map;var _PublicKey;const MAX_SEED_LENGTH=32;const PUBLIC_KEY_LENGTH=32;function isPublicKeyData(value){return value._bn!==undefined}let uniquePublicKeyCounter=1;class PublicKey extends Struct$1{constructor(value){super({});this._bn=void 0;if(isPublicKeyData(value)){this._bn=value._bn}else{if(typeof value==="string"){const decoded=bs58.decode(value);if(decoded.length!=PUBLIC_KEY_LENGTH){throw new Error(`Invalid public key input`)}this._bn=new BN(decoded)}else{this._bn=new BN(value)}if(this._bn.byteLength()>PUBLIC_KEY_LENGTH){throw new Error(`Invalid public key input`)}}}static unique(){const key=new PublicKey(uniquePublicKeyCounter);uniquePublicKeyCounter+=1;return new PublicKey(key.toBuffer())}equals(publicKey){return this._bn.eq(publicKey._bn)}toBase58(){return bs58.encode(this.toBytes())}toJSON(){return this.toBase58()}toBytes(){const buf=this.toBuffer();return new Uint8Array(buf.buffer,buf.byteOffset,buf.byteLength)}toBuffer(){const b=this._bn.toArrayLike(bufferExports.Buffer);if(b.length===PUBLIC_KEY_LENGTH){return b}const zeroPad=bufferExports.Buffer.alloc(32);b.copy(zeroPad,32-b.length);return zeroPad}get[Symbol.toStringTag](){return`PublicKey(${this.toString()})`}toString(){return this.toBase58()}static async createWithSeed(fromPublicKey,seed,programId){const buffer=bufferExports.Buffer.concat([fromPublicKey.toBuffer(),bufferExports.Buffer.from(seed),programId.toBuffer()]);const publicKeyBytes=sha256$1(buffer);return new PublicKey(publicKeyBytes)}static createProgramAddressSync(seeds,programId){let buffer=bufferExports.Buffer.alloc(0);seeds.forEach((function(seed){if(seed.length>MAX_SEED_LENGTH){throw new TypeError(`Max seed length exceeded`)}buffer=bufferExports.Buffer.concat([buffer,toBuffer(seed)])}));buffer=bufferExports.Buffer.concat([buffer,programId.toBuffer(),bufferExports.Buffer.from("ProgramDerivedAddress")]);const publicKeyBytes=sha256$1(buffer);if(isOnCurve(publicKeyBytes)){throw new Error(`Invalid seeds, address must fall off the curve`)}return new PublicKey(publicKeyBytes)}static async createProgramAddress(seeds,programId){return this.createProgramAddressSync(seeds,programId)}static findProgramAddressSync(seeds,programId){let nonce=255;let address;while(nonce!=0){try{const seedsWithNonce=seeds.concat(bufferExports.Buffer.from([nonce]));address=this.createProgramAddressSync(seedsWithNonce,programId)}catch(err){if(err instanceof TypeError){throw err}nonce--;continue}return[address,nonce]}throw new Error(`Unable to find a viable program address nonce`)}static async findProgramAddress(seeds,programId){return this.findProgramAddressSync(seeds,programId)}static isOnCurve(pubkeyData){const pubkey=new PublicKey(pubkeyData);return isOnCurve(pubkey.toBytes())}}_PublicKey=PublicKey;PublicKey.default=new _PublicKey("11111111111111111111111111111111");SOLANA_SCHEMA.set(PublicKey,{kind:"struct",fields:[["_bn","u256"]]});class Account{constructor(secretKey){this._publicKey=void 0;this._secretKey=void 0;if(secretKey){const secretKeyBuffer=toBuffer(secretKey);if(secretKey.length!==64){throw new Error("bad secret key size")}this._publicKey=secretKeyBuffer.slice(32,64);this._secretKey=secretKeyBuffer.slice(0,32)}else{this._secretKey=toBuffer(generatePrivateKey());this._publicKey=toBuffer(getPublicKey(this._secretKey))}}get publicKey(){return new PublicKey(this._publicKey)}get secretKey(){return bufferExports.Buffer.concat([this._secretKey,this._publicKey],64)}}const BPF_LOADER_DEPRECATED_PROGRAM_ID=new PublicKey("BPFLoader1111111111111111111111111111111111");var Layout={};var hasRequiredLayout;function requireLayout(){if(hasRequiredLayout)return Layout;hasRequiredLayout=1;Object.defineProperty(Layout,"__esModule",{value:true});Layout.s16=Layout.s8=Layout.nu64be=Layout.u48be=Layout.u40be=Layout.u32be=Layout.u24be=Layout.u16be=Layout.nu64=Layout.u48=Layout.u40=Layout.u32=Layout.u24=Layout.u16=Layout.u8=Layout.offset=Layout.greedy=Layout.Constant=Layout.UTF8=Layout.CString=Layout.Blob=Layout.Boolean=Layout.BitField=Layout.BitStructure=Layout.VariantLayout=Layout.Union=Layout.UnionLayoutDiscriminator=Layout.UnionDiscriminator=Layout.Structure=Layout.Sequence=Layout.DoubleBE=Layout.Double=Layout.FloatBE=Layout.Float=Layout.NearInt64BE=Layout.NearInt64=Layout.NearUInt64BE=Layout.NearUInt64=Layout.IntBE=Layout.Int=Layout.UIntBE=Layout.UInt=Layout.OffsetLayout=Layout.GreedyCount=Layout.ExternalLayout=Layout.bindConstructorLayout=Layout.nameWithProperty=Layout.Layout=Layout.uint8ArrayToBuffer=Layout.checkUint8Array=void 0;Layout.constant=Layout.utf8=Layout.cstr=Layout.blob=Layout.unionLayoutDiscriminator=Layout.union=Layout.seq=Layout.bits=Layout.struct=Layout.f64be=Layout.f64=Layout.f32be=Layout.f32=Layout.ns64be=Layout.s48be=Layout.s40be=Layout.s32be=Layout.s24be=Layout.s16be=Layout.ns64=Layout.s48=Layout.s40=Layout.s32=Layout.s24=void 0;const buffer_1=requireBuffer();function checkUint8Array(b){if(!(b instanceof Uint8Array)){throw new TypeError("b must be a Uint8Array")}}Layout.checkUint8Array=checkUint8Array;function uint8ArrayToBuffer(b){checkUint8Array(b);return buffer_1.Buffer.from(b.buffer,b.byteOffset,b.length)}Layout.uint8ArrayToBuffer=uint8ArrayToBuffer;let Layout$1=class Layout{constructor(span,property){if(!Number.isInteger(span)){throw new TypeError("span must be an integer")}this.span=span;this.property=property}makeDestinationObject(){return{}}getSpan(b,offset){if(0>this.span){throw new RangeError("indeterminate span")}return this.span}replicate(property){const rv=Object.create(this.constructor.prototype);Object.assign(rv,this);rv.property=property;return rv}fromArray(values){return undefined}};Layout.Layout=Layout$1;function nameWithProperty(name,lo){if(lo.property){return name+"["+lo.property+"]"}return name}Layout.nameWithProperty=nameWithProperty;function bindConstructorLayout(Class,layout){if("function"!==typeof Class){throw new TypeError("Class must be constructor")}if(Object.prototype.hasOwnProperty.call(Class,"layout_")){throw new Error("Class is already bound to a layout")}if(!(layout&&layout instanceof Layout$1)){throw new TypeError("layout must be a Layout")}if(Object.prototype.hasOwnProperty.call(layout,"boundConstructor_")){throw new Error("layout is already bound to a constructor")}Class.layout_=layout;layout.boundConstructor_=Class;layout.makeDestinationObject=()=>new Class;Object.defineProperty(Class.prototype,"encode",{value(b,offset){return layout.encode(this,b,offset)},writable:true});Object.defineProperty(Class,"decode",{value(b,offset){return layout.decode(b,offset)},writable:true})}Layout.bindConstructorLayout=bindConstructorLayout;class ExternalLayout extends Layout$1{isCount(){throw new Error("ExternalLayout is abstract")}}Layout.ExternalLayout=ExternalLayout;class GreedyCount extends ExternalLayout{constructor(elementSpan=1,property){if(!Number.isInteger(elementSpan)||0>=elementSpan){throw new TypeError("elementSpan must be a (positive) integer")}super(-1,property);this.elementSpan=elementSpan}isCount(){return true}decode(b,offset=0){checkUint8Array(b);const rem=b.length-offset;return Math.floor(rem/this.elementSpan)}encode(src,b,offset){return 0}}Layout.GreedyCount=GreedyCount;class OffsetLayout extends ExternalLayout{constructor(layout,offset=0,property){if(!(layout instanceof Layout$1)){throw new TypeError("layout must be a Layout")}if(!Number.isInteger(offset)){throw new TypeError("offset must be integer or undefined")}super(layout.span,property||layout.property);this.layout=layout;this.offset=offset}isCount(){return this.layout instanceof UInt||this.layout instanceof UIntBE}decode(b,offset=0){return this.layout.decode(b,offset+this.offset)}encode(src,b,offset=0){return this.layout.encode(src,b,offset+this.offset)}}Layout.OffsetLayout=OffsetLayout;class UInt extends Layout$1{constructor(span,property){super(span,property);if(6span+elo.encode(v,b,offset+span)),0);if(this.count instanceof ExternalLayout){this.count.encode(src.length,b,offset)}return span}}Layout.Sequence=Sequence;class Structure extends Layout$1{constructor(fields,property,decodePrefixes){if(!(Array.isArray(fields)&&fields.reduce(((acc,v)=>acc&&v instanceof Layout$1),true))){throw new TypeError("fields must be array of Layout instances")}if("boolean"===typeof property&&undefined===decodePrefixes){decodePrefixes=property;property=undefined}for(const fd of fields){if(0>fd.span&&undefined===fd.property){throw new Error("fields cannot contain unnamed variable-length layout")}}let span=-1;try{span=fields.reduce(((span,fd)=>span+fd.getSpan()),0)}catch(e){}super(span,property);this.fields=fields;this.decodePrefixes=!!decodePrefixes}getSpan(b,offset=0){if(0<=this.span){return this.span}let span=0;try{span=this.fields.reduce(((span,fd)=>{const fsp=fd.getSpan(b,offset);offset+=fsp;return span+fsp}),0)}catch(e){throw new RangeError("indeterminate span")}return span}decode(b,offset=0){checkUint8Array(b);const dest=this.makeDestinationObject();for(const fd of this.fields){if(undefined!==fd.property){dest[fd.property]=fd.decode(b,offset)}offset+=fd.getSpan(b,offset);if(this.decodePrefixes&&b.length===offset){break}}return dest}encode(src,b,offset=0){const firstOffset=offset;let lastOffset=0;let lastWrote=0;for(const fd of this.fields){let span=fd.span;lastWrote=0span){span=fd.getSpan(b,offset)}}}lastOffset=offset;offset+=span}return lastOffset+lastWrote-firstOffset}fromArray(values){const dest=this.makeDestinationObject();for(const fd of this.fields){if(undefined!==fd.property&&0fd.span){offset=-1}else if(0<=offset){offset+=fd.span}}return undefined}}Layout.Structure=Structure;class UnionDiscriminator{constructor(property){this.property=property}decode(b,offset){throw new Error("UnionDiscriminator is abstract")}encode(src,b,offset){throw new Error("UnionDiscriminator is abstract")}}Layout.UnionDiscriminator=UnionDiscriminator;class UnionLayoutDiscriminator extends UnionDiscriminator{constructor(layout,property){if(!(layout instanceof ExternalLayout&&layout.isCount())){throw new TypeError("layout must be an unsigned integer ExternalLayout")}super(property||layout.property||"variant");this.layout=layout}decode(b,offset){return this.layout.decode(b,offset)}encode(src,b,offset){return this.layout.encode(src,b,offset)}}Layout.UnionLayoutDiscriminator=UnionLayoutDiscriminator;class Union extends Layout$1{constructor(discr,defaultLayout,property){let discriminator;if(discr instanceof UInt||discr instanceof UIntBE){discriminator=new UnionLayoutDiscriminator(new OffsetLayout(discr))}else if(discr instanceof ExternalLayout&&discr.isCount()){discriminator=new UnionLayoutDiscriminator(discr)}else if(!(discr instanceof UnionDiscriminator)){throw new TypeError("discr must be a UnionDiscriminator "+"or an unsigned integer layout")}else{discriminator=discr}if(undefined===defaultLayout){defaultLayout=null}if(!(null===defaultLayout||defaultLayout instanceof Layout$1)){throw new TypeError("defaultLayout must be null or a Layout")}if(null!==defaultLayout){if(0>defaultLayout.span){throw new Error("defaultLayout must have constant span")}if(undefined===defaultLayout.property){defaultLayout=defaultLayout.replicate("content")}}let span=-1;if(defaultLayout){span=defaultLayout.span;if(0<=span&&(discr instanceof UInt||discr instanceof UIntBE)){span+=discriminator.layout.span}}super(span,property);this.discriminator=discriminator;this.usesPrefixDiscriminator=discr instanceof UInt||discr instanceof UIntBE;this.defaultLayout=defaultLayout;this.registry={};let boundGetSourceVariant=this.defaultGetSourceVariant.bind(this);this.getSourceVariant=function(src){return boundGetSourceVariant(src)};this.configGetSourceVariant=function(gsv){boundGetSourceVariant=gsv.bind(this)}}getSpan(b,offset=0){if(0<=this.span){return this.span}const vlo=this.getVariant(b,offset);if(!vlo){throw new Error("unable to determine span for unrecognized variant")}return vlo.getSpan(b,offset)}defaultGetSourceVariant(src){if(Object.prototype.hasOwnProperty.call(src,this.discriminator.property)){if(this.defaultLayout&&this.defaultLayout.property&&Object.prototype.hasOwnProperty.call(src,this.defaultLayout.property)){return undefined}const vlo=this.registry[src[this.discriminator.property]];if(vlo&&(!vlo.layout||vlo.property&&Object.prototype.hasOwnProperty.call(src,vlo.property))){return vlo}}else{for(const tag in this.registry){const vlo=this.registry[tag];if(vlo.property&&Object.prototype.hasOwnProperty.call(src,vlo.property)){return vlo}}}throw new Error("unable to infer src variant")}decode(b,offset=0){let dest;const dlo=this.discriminator;const discr=dlo.decode(b,offset);const clo=this.registry[discr];if(undefined===clo){const defaultLayout=this.defaultLayout;let contentOffset=0;if(this.usesPrefixDiscriminator){contentOffset=dlo.layout.span}dest=this.makeDestinationObject();dest[dlo.property]=discr;dest[defaultLayout.property]=defaultLayout.decode(b,offset+contentOffset)}else{dest=clo.decode(b,offset)}return dest}encode(src,b,offset=0){const vlo=this.getSourceVariant(src);if(undefined===vlo){const dlo=this.discriminator;const clo=this.defaultLayout;let contentOffset=0;if(this.usesPrefixDiscriminator){contentOffset=dlo.layout.span}dlo.encode(src[dlo.property],b,offset);return contentOffset+clo.encode(src[clo.property],b,offset+contentOffset)}return vlo.encode(src,b,offset)}addVariant(variant,layout,property){const rv=new VariantLayout(this,variant,layout,property);this.registry[variant]=rv;return rv}getVariant(vb,offset=0){let variant;if(vb instanceof Uint8Array){variant=this.discriminator.decode(vb,offset)}else{variant=vb}return this.registry[variant]}}Layout.Union=Union;class VariantLayout extends Layout$1{constructor(union,variant,layout,property){if(!(union instanceof Union)){throw new TypeError("union must be a Union")}if(!Number.isInteger(variant)||0>variant){throw new TypeError("variant must be a (non-negative) integer")}if("string"===typeof layout&&undefined===property){property=layout;layout=null}if(layout){if(!(layout instanceof Layout$1)){throw new TypeError("layout must be a Layout")}if(null!==union.defaultLayout&&0<=layout.span&&layout.span>union.defaultLayout.span){throw new Error("variant span exceeds span of containing union")}if("string"!==typeof property){throw new TypeError("variant must have a String property")}}let span=union.span;if(0>union.span){span=layout?layout.span:0;if(0<=span&&union.usesPrefixDiscriminator){span+=union.discriminator.layout.span}}super(span,property);this.union=union;this.variant=variant;this.layout=layout||null}getSpan(b,offset=0){if(0<=this.span){return this.span}let contentOffset=0;if(this.union.usesPrefixDiscriminator){contentOffset=this.union.discriminator.layout.span}let span=0;if(this.layout){span=this.layout.getSpan(b,offset+contentOffset)}return contentOffset+span}decode(b,offset=0){const dest=this.makeDestinationObject();if(this!==this.union.getVariant(b,offset)){throw new Error("variant mismatch")}let contentOffset=0;if(this.union.usesPrefixDiscriminator){contentOffset=this.union.discriminator.layout.span}if(this.layout){dest[this.property]=this.layout.decode(b,offset+contentOffset)}else if(this.property){dest[this.property]=true}else if(this.union.usesPrefixDiscriminator){dest[this.union.discriminator.property]=this.variant}return dest}encode(src,b,offset=0){let contentOffset=0;if(this.union.usesPrefixDiscriminator){contentOffset=this.union.discriminator.layout.span}if(this.layout&&!Object.prototype.hasOwnProperty.call(src,this.property)){throw new TypeError("variant lacks property "+this.property)}this.union.discriminator.encode(this.variant,b,offset);let span=contentOffset;if(this.layout){this.layout.encode(src[this.property],b,offset+contentOffset);span+=this.layout.getSpan(b,offset+contentOffset);if(0<=this.union.span&&span>this.union.span){throw new Error("encoded variant overruns containing union")}}return span}fromArray(values){if(this.layout){return this.layout.fromArray(values)}return undefined}}Layout.VariantLayout=VariantLayout;function fixBitwiseResult(v){if(0>v){v+=4294967296}return v}class BitStructure extends Layout$1{constructor(word,msb,property){if(!(word instanceof UInt||word instanceof UIntBE)){throw new TypeError("word must be a UInt or UIntBE layout")}if("string"===typeof msb&&undefined===property){property=msb;msb=false}if(4=bits){throw new TypeError("bits must be positive integer")}const totalBits=8*container.span;const usedBits=container.fields.reduce(((sum,fd)=>sum+fd.bits),0);if(bits+usedBits>totalBits){throw new Error("bits too long for span remainder ("+(totalBits-usedBits)+" of "+totalBits+" remain)")}this.container=container;this.bits=bits;this.valueMask=(1<>>this.start;return value}encode(value){if("number"!==typeof value||!Number.isInteger(value)||value!==fixBitwiseResult(value&this.valueMask)){throw new TypeError(nameWithProperty("BitField.encode",this)+" value must be integer not exceeding "+this.valueMask)}const word=this.container._packedGetValue();const wordValue=fixBitwiseResult(value<span){span=this.length.decode(b,offset)}return span}decode(b,offset=0){let span=this.span;if(0>span){span=this.length.decode(b,offset)}return uint8ArrayToBuffer(b).slice(offset,offset+span)}encode(src,b,offset){let span=this.length;if(this.length instanceof ExternalLayout){span=src.length}if(!(src instanceof Uint8Array&&span===src.length)){throw new TypeError(nameWithProperty("Blob.encode",this)+" requires (length "+span+") Uint8Array as src")}if(offset+span>b.length){throw new RangeError("encoding overruns Uint8Array")}const srcBuffer=uint8ArrayToBuffer(src);uint8ArrayToBuffer(b).write(srcBuffer.toString("hex"),offset,span,"hex");if(this.length instanceof ExternalLayout){this.length.encode(span,b,offset)}return span}}Layout.Blob=Blob;class CString extends Layout$1{constructor(property){super(-1,property)}getSpan(b,offset=0){checkUint8Array(b);let idx=offset;while(idxb.length){throw new RangeError("encoding overruns Buffer")}const buffer=uint8ArrayToBuffer(b);srcb.copy(buffer,offset);buffer[offset+span]=0;return span+1}}Layout.CString=CString;class UTF8 extends Layout$1{constructor(maxSpan,property){if("string"===typeof maxSpan&&undefined===property){property=maxSpan;maxSpan=undefined}if(undefined===maxSpan){maxSpan=-1}else if(!Number.isInteger(maxSpan)){throw new TypeError("maxSpan must be an integer")}super(-1,property);this.maxSpan=maxSpan}getSpan(b,offset=0){checkUint8Array(b);return b.length-offset}decode(b,offset=0){const span=this.getSpan(b,offset);if(0<=this.maxSpan&&this.maxSpanb.length){throw new RangeError("encoding overruns Buffer")}srcb.copy(uint8ArrayToBuffer(b),offset);return span}}Layout.UTF8=UTF8;class Constant extends Layout$1{constructor(value,property){super(0,property);this.value=value}decode(b,offset){return this.value}encode(src,b,offset){return 0}}Layout.Constant=Constant;Layout.greedy=(elementSpan,property)=>new GreedyCount(elementSpan,property);Layout.offset=(layout,offset,property)=>new OffsetLayout(layout,offset,property);Layout.u8=property=>new UInt(1,property);Layout.u16=property=>new UInt(2,property);Layout.u24=property=>new UInt(3,property);Layout.u32=property=>new UInt(4,property);Layout.u40=property=>new UInt(5,property);Layout.u48=property=>new UInt(6,property);Layout.nu64=property=>new NearUInt64(property);Layout.u16be=property=>new UIntBE(2,property);Layout.u24be=property=>new UIntBE(3,property);Layout.u32be=property=>new UIntBE(4,property);Layout.u40be=property=>new UIntBE(5,property);Layout.u48be=property=>new UIntBE(6,property);Layout.nu64be=property=>new NearUInt64BE(property);Layout.s8=property=>new Int(1,property);Layout.s16=property=>new Int(2,property);Layout.s24=property=>new Int(3,property);Layout.s32=property=>new Int(4,property);Layout.s40=property=>new Int(5,property);Layout.s48=property=>new Int(6,property);Layout.ns64=property=>new NearInt64(property);Layout.s16be=property=>new IntBE(2,property);Layout.s24be=property=>new IntBE(3,property);Layout.s32be=property=>new IntBE(4,property);Layout.s40be=property=>new IntBE(5,property);Layout.s48be=property=>new IntBE(6,property);Layout.ns64be=property=>new NearInt64BE(property);Layout.f32=property=>new Float(property);Layout.f32be=property=>new FloatBE(property);Layout.f64=property=>new Double(property);Layout.f64be=property=>new DoubleBE(property);Layout.struct=(fields,property,decodePrefixes)=>new Structure(fields,property,decodePrefixes);Layout.bits=(word,msb,property)=>new BitStructure(word,msb,property);Layout.seq=(elementLayout,count,property)=>new Sequence(elementLayout,count,property);Layout.union=(discr,defaultLayout,property)=>new Union(discr,defaultLayout,property);Layout.unionLayoutDiscriminator=(layout,property)=>new UnionLayoutDiscriminator(layout,property);Layout.blob=(length,property)=>new Blob(length,property);Layout.cstr=property=>new CString(property);Layout.utf8=(maxSpan,property)=>new UTF8(maxSpan,property);Layout.constant=(value,property)=>new Constant(value,property);return Layout}var LayoutExports=requireLayout();const PACKET_DATA_SIZE=1280-40-8;const VERSION_PREFIX_MASK=127;const SIGNATURE_LENGTH_IN_BYTES=64;class TransactionExpiredBlockheightExceededError extends Error{constructor(signature){super(`Signature ${signature} has expired: block height exceeded.`);this.signature=void 0;this.signature=signature}}Object.defineProperty(TransactionExpiredBlockheightExceededError.prototype,"name",{value:"TransactionExpiredBlockheightExceededError"});class TransactionExpiredTimeoutError extends Error{constructor(signature,timeoutSeconds){super(`Transaction was not confirmed in ${timeoutSeconds.toFixed(2)} seconds. It is `+"unknown if it succeeded or failed. Check signature "+`${signature} using the Solana Explorer or CLI tools.`);this.signature=void 0;this.signature=signature}}Object.defineProperty(TransactionExpiredTimeoutError.prototype,"name",{value:"TransactionExpiredTimeoutError"});class TransactionExpiredNonceInvalidError extends Error{constructor(signature){super(`Signature ${signature} has expired: the nonce is no longer valid.`);this.signature=void 0;this.signature=signature}}Object.defineProperty(TransactionExpiredNonceInvalidError.prototype,"name",{value:"TransactionExpiredNonceInvalidError"});class MessageAccountKeys{constructor(staticAccountKeys,accountKeysFromLookups){this.staticAccountKeys=void 0;this.accountKeysFromLookups=void 0;this.staticAccountKeys=staticAccountKeys;this.accountKeysFromLookups=accountKeysFromLookups}keySegments(){const keySegments=[this.staticAccountKeys];if(this.accountKeysFromLookups){keySegments.push(this.accountKeysFromLookups.writable);keySegments.push(this.accountKeysFromLookups.readonly)}return keySegments}get(index){for(const keySegment of this.keySegments()){if(indexU8_MAX+1){throw new Error("Account index overflow encountered during compilation")}const keyIndexMap=new Map;this.keySegments().flat().forEach(((key,index)=>{keyIndexMap.set(key.toBase58(),index)}));const findKeyIndex=key=>{const keyIndex=keyIndexMap.get(key.toBase58());if(keyIndex===undefined)throw new Error("Encountered an unknown instruction account key during compilation");return keyIndex};return instructions.map((instruction=>({programIdIndex:findKeyIndex(instruction.programId),accountKeyIndexes:instruction.keys.map((meta=>findKeyIndex(meta.pubkey))),data:instruction.data})))}}const publicKey=(property="publicKey")=>LayoutExports.blob(32,property);const signature=(property="signature")=>LayoutExports.blob(64,property);const rustString=(property="string")=>{const rsl=LayoutExports.struct([LayoutExports.u32("length"),LayoutExports.u32("lengthPadding"),LayoutExports.blob(LayoutExports.offset(LayoutExports.u32(),-8),"chars")],property);const _decode=rsl.decode.bind(rsl);const _encode=rsl.encode.bind(rsl);const rslShim=rsl;rslShim.decode=(b,offset)=>{const data=_decode(b,offset);return data["chars"].toString()};rslShim.encode=(str,b,offset)=>{const data={chars:bufferExports.Buffer.from(str,"utf8")};return _encode(data,b,offset)};rslShim.alloc=str=>LayoutExports.u32().span+LayoutExports.u32().span+bufferExports.Buffer.from(str,"utf8").length;return rslShim};const authorized=(property="authorized")=>LayoutExports.struct([publicKey("staker"),publicKey("withdrawer")],property);const lockup=(property="lockup")=>LayoutExports.struct([LayoutExports.ns64("unixTimestamp"),LayoutExports.ns64("epoch"),publicKey("custodian")],property);const voteInit=(property="voteInit")=>LayoutExports.struct([publicKey("nodePubkey"),publicKey("authorizedVoter"),publicKey("authorizedWithdrawer"),LayoutExports.u8("commission")],property);const voteAuthorizeWithSeedArgs=(property="voteAuthorizeWithSeedArgs")=>LayoutExports.struct([LayoutExports.u32("voteAuthorizationType"),publicKey("currentAuthorityDerivedKeyOwnerPubkey"),rustString("currentAuthorityDerivedKeySeed"),publicKey("newAuthorized")],property);function getAlloc(type,fields){const getItemAlloc=item=>{if(item.span>=0){return item.span}else if(typeof item.alloc==="function"){return item.alloc(fields[item.property])}else if("count"in item&&"elementLayout"in item){const field=fields[item.property];if(Array.isArray(field)){return field.length*getItemAlloc(item.elementLayout)}}else if("fields"in item){return getAlloc({layout:item},fields[item.property])}return 0};let alloc=0;type.layout.fields.forEach((item=>{alloc+=getItemAlloc(item)}));return alloc}function decodeLength(bytes){let len=0;let size=0;for(;;){let elem=bytes.shift();len|=(elem&127)<>=7;if(rem_len==0){bytes.push(elem);break}else{elem|=128;bytes.push(elem)}}}function assert$1(condition,message){if(!condition){throw new Error(message||"Assertion failed")}}class CompiledKeys{constructor(payer,keyMetaMap){this.payer=void 0;this.keyMetaMap=void 0;this.payer=payer;this.keyMetaMap=keyMetaMap}static compile(instructions,payer){const keyMetaMap=new Map;const getOrInsertDefault=pubkey=>{const address=pubkey.toBase58();let keyMeta=keyMetaMap.get(address);if(keyMeta===undefined){keyMeta={isSigner:false,isWritable:false,isInvoked:false};keyMetaMap.set(address,keyMeta)}return keyMeta};const payerKeyMeta=getOrInsertDefault(payer);payerKeyMeta.isSigner=true;payerKeyMeta.isWritable=true;for(const ix of instructions){getOrInsertDefault(ix.programId).isInvoked=true;for(const accountMeta of ix.keys){const keyMeta=getOrInsertDefault(accountMeta.pubkey);keyMeta.isSigner||=accountMeta.isSigner;keyMeta.isWritable||=accountMeta.isWritable}}return new CompiledKeys(payer,keyMetaMap)}getMessageComponents(){const mapEntries=[...this.keyMetaMap.entries()];assert$1(mapEntries.length<=256,"Max static account keys length exceeded");const writableSigners=mapEntries.filter((([,meta])=>meta.isSigner&&meta.isWritable));const readonlySigners=mapEntries.filter((([,meta])=>meta.isSigner&&!meta.isWritable));const writableNonSigners=mapEntries.filter((([,meta])=>!meta.isSigner&&meta.isWritable));const readonlyNonSigners=mapEntries.filter((([,meta])=>!meta.isSigner&&!meta.isWritable));const header={numRequiredSignatures:writableSigners.length+readonlySigners.length,numReadonlySignedAccounts:readonlySigners.length,numReadonlyUnsignedAccounts:readonlyNonSigners.length};{assert$1(writableSigners.length>0,"Expected at least one writable signer key");const[payerAddress]=writableSigners[0];assert$1(payerAddress===this.payer.toBase58(),"Expected first writable signer key to be the fee payer")}const staticAccountKeys=[...writableSigners.map((([address])=>new PublicKey(address))),...readonlySigners.map((([address])=>new PublicKey(address))),...writableNonSigners.map((([address])=>new PublicKey(address))),...readonlyNonSigners.map((([address])=>new PublicKey(address)))];return[header,staticAccountKeys]}extractTableLookup(lookupTable){const[writableIndexes,drainedWritableKeys]=this.drainKeysFoundInLookupTable(lookupTable.state.addresses,(keyMeta=>!keyMeta.isSigner&&!keyMeta.isInvoked&&keyMeta.isWritable));const[readonlyIndexes,drainedReadonlyKeys]=this.drainKeysFoundInLookupTable(lookupTable.state.addresses,(keyMeta=>!keyMeta.isSigner&&!keyMeta.isInvoked&&!keyMeta.isWritable));if(writableIndexes.length===0&&readonlyIndexes.length===0){return}return[{accountKey:lookupTable.key,writableIndexes:writableIndexes,readonlyIndexes:readonlyIndexes},{writable:drainedWritableKeys,readonly:drainedReadonlyKeys}]}drainKeysFoundInLookupTable(lookupTableEntries,keyMetaFilter){const lookupTableIndexes=new Array;const drainedKeys=new Array;for(const[address,keyMeta]of this.keyMetaMap.entries()){if(keyMetaFilter(keyMeta)){const key=new PublicKey(address);const lookupTableIndex=lookupTableEntries.findIndex((entry=>entry.equals(key)));if(lookupTableIndex>=0){assert$1(lookupTableIndex<256,"Max lookup table index exceeded");lookupTableIndexes.push(lookupTableIndex);drainedKeys.push(key);this.keyMetaMap.delete(address)}}}return[lookupTableIndexes,drainedKeys]}}const END_OF_BUFFER_ERROR_MESSAGE="Reached end of buffer unexpectedly";function guardedShift(byteArray){if(byteArray.length===0){throw new Error(END_OF_BUFFER_ERROR_MESSAGE)}return byteArray.shift()}function guardedSplice(byteArray,...args){const[start]=args;if(args.length===2?start+(args[1]??0)>byteArray.length:start>=byteArray.length){throw new Error(END_OF_BUFFER_ERROR_MESSAGE)}return byteArray.splice(...args)}class Message{constructor(args){this.header=void 0;this.accountKeys=void 0;this.recentBlockhash=void 0;this.instructions=void 0;this.indexToProgramIds=new Map;this.header=args.header;this.accountKeys=args.accountKeys.map((account=>new PublicKey(account)));this.recentBlockhash=args.recentBlockhash;this.instructions=args.instructions;this.instructions.forEach((ix=>this.indexToProgramIds.set(ix.programIdIndex,this.accountKeys[ix.programIdIndex])))}get version(){return"legacy"}get staticAccountKeys(){return this.accountKeys}get compiledInstructions(){return this.instructions.map((ix=>({programIdIndex:ix.programIdIndex,accountKeyIndexes:ix.accounts,data:bs58.decode(ix.data)})))}get addressTableLookups(){return[]}getAccountKeys(){return new MessageAccountKeys(this.staticAccountKeys)}static compile(args){const compiledKeys=CompiledKeys.compile(args.instructions,args.payerKey);const[header,staticAccountKeys]=compiledKeys.getMessageComponents();const accountKeys=new MessageAccountKeys(staticAccountKeys);const instructions=accountKeys.compileInstructions(args.instructions).map((ix=>({programIdIndex:ix.programIdIndex,accounts:ix.accountKeyIndexes,data:bs58.encode(ix.data)})));return new Message({header:header,accountKeys:staticAccountKeys,recentBlockhash:args.recentBlockhash,instructions:instructions})}isAccountSigner(index){return index=this.header.numRequiredSignatures){const unsignedAccountIndex=index-numSignedAccounts;const numUnsignedAccounts=this.accountKeys.length-numSignedAccounts;const numWritableUnsignedAccounts=numUnsignedAccounts-this.header.numReadonlyUnsignedAccounts;return unsignedAccountIndex!this.isProgramId(index)))}serialize(){const numKeys=this.accountKeys.length;let keyCount=[];encodeLength(keyCount,numKeys);const instructions=this.instructions.map((instruction=>{const{accounts:accounts,programIdIndex:programIdIndex}=instruction;const data=Array.from(bs58.decode(instruction.data));let keyIndicesCount=[];encodeLength(keyIndicesCount,accounts.length);let dataCount=[];encodeLength(dataCount,data.length);return{programIdIndex:programIdIndex,keyIndicesCount:bufferExports.Buffer.from(keyIndicesCount),keyIndices:accounts,dataLength:bufferExports.Buffer.from(dataCount),data:data}}));let instructionCount=[];encodeLength(instructionCount,instructions.length);let instructionBuffer=bufferExports.Buffer.alloc(PACKET_DATA_SIZE);bufferExports.Buffer.from(instructionCount).copy(instructionBuffer);let instructionBufferLength=instructionCount.length;instructions.forEach((instruction=>{const instructionLayout=LayoutExports.struct([LayoutExports.u8("programIdIndex"),LayoutExports.blob(instruction.keyIndicesCount.length,"keyIndicesCount"),LayoutExports.seq(LayoutExports.u8("keyIndex"),instruction.keyIndices.length,"keyIndices"),LayoutExports.blob(instruction.dataLength.length,"dataLength"),LayoutExports.seq(LayoutExports.u8("userdatum"),instruction.data.length,"data")]);const length=instructionLayout.encode(instruction,instructionBuffer,instructionBufferLength);instructionBufferLength+=length}));instructionBuffer=instructionBuffer.slice(0,instructionBufferLength);const signDataLayout=LayoutExports.struct([LayoutExports.blob(1,"numRequiredSignatures"),LayoutExports.blob(1,"numReadonlySignedAccounts"),LayoutExports.blob(1,"numReadonlyUnsignedAccounts"),LayoutExports.blob(keyCount.length,"keyCount"),LayoutExports.seq(publicKey("key"),numKeys,"keys"),publicKey("recentBlockhash")]);const transaction={numRequiredSignatures:bufferExports.Buffer.from([this.header.numRequiredSignatures]),numReadonlySignedAccounts:bufferExports.Buffer.from([this.header.numReadonlySignedAccounts]),numReadonlyUnsignedAccounts:bufferExports.Buffer.from([this.header.numReadonlyUnsignedAccounts]),keyCount:bufferExports.Buffer.from(keyCount),keys:this.accountKeys.map((key=>toBuffer(key.toBytes()))),recentBlockhash:bs58.decode(this.recentBlockhash)};let signData=bufferExports.Buffer.alloc(2048);const length=signDataLayout.encode(transaction,signData);instructionBuffer.copy(signData,length);return signData.slice(0,length+instructionBuffer.length)}static from(buffer){let byteArray=[...buffer];const numRequiredSignatures=guardedShift(byteArray);if(numRequiredSignatures!==(numRequiredSignatures&VERSION_PREFIX_MASK)){throw new Error("Versioned messages must be deserialized with VersionedMessage.deserialize()")}const numReadonlySignedAccounts=guardedShift(byteArray);const numReadonlyUnsignedAccounts=guardedShift(byteArray);const accountCount=decodeLength(byteArray);let accountKeys=[];for(let i=0;i0){throw new Error("Failed to get account keys because address table lookups were not resolved")}return new MessageAccountKeys(this.staticAccountKeys,accountKeysFromLookups)}isAccountSigner(index){return index=numStaticAccountKeys){const lookupAccountKeysIndex=index-numStaticAccountKeys;const numWritableLookupAccountKeys=this.addressTableLookups.reduce(((count,lookup)=>count+lookup.writableIndexes.length),0);return lookupAccountKeysIndex=this.header.numRequiredSignatures){const unsignedAccountIndex=index-numSignedAccounts;const numUnsignedAccounts=numStaticAccountKeys-numSignedAccounts;const numWritableUnsignedAccounts=numUnsignedAccounts-this.header.numReadonlyUnsignedAccounts;return unsignedAccountIndexaccount.key.equals(tableLookup.accountKey)));if(!tableAccount){throw new Error(`Failed to find address lookup table account for table key ${tableLookup.accountKey.toBase58()}`)}for(const index of tableLookup.writableIndexes){if(indexkey.toBytes())),recentBlockhash:bs58.decode(this.recentBlockhash),instructionsLength:new Uint8Array(encodedInstructionsLength),serializedInstructions:serializedInstructions,addressTableLookupsLength:new Uint8Array(encodedAddressTableLookupsLength),serializedAddressTableLookups:serializedAddressTableLookups},serializedMessage);return serializedMessage.slice(0,serializedMessageLength)}serializeInstructions(){let serializedLength=0;const serializedInstructions=new Uint8Array(PACKET_DATA_SIZE);for(const instruction of this.compiledInstructions){const encodedAccountKeyIndexesLength=Array();encodeLength(encodedAccountKeyIndexesLength,instruction.accountKeyIndexes.length);const encodedDataLength=Array();encodeLength(encodedDataLength,instruction.data.length);const instructionLayout=LayoutExports.struct([LayoutExports.u8("programIdIndex"),LayoutExports.blob(encodedAccountKeyIndexesLength.length,"encodedAccountKeyIndexesLength"),LayoutExports.seq(LayoutExports.u8(),instruction.accountKeyIndexes.length,"accountKeyIndexes"),LayoutExports.blob(encodedDataLength.length,"encodedDataLength"),LayoutExports.blob(instruction.data.length,"data")]);serializedLength+=instructionLayout.encode({programIdIndex:instruction.programIdIndex,encodedAccountKeyIndexesLength:new Uint8Array(encodedAccountKeyIndexesLength),accountKeyIndexes:instruction.accountKeyIndexes,encodedDataLength:new Uint8Array(encodedDataLength),data:instruction.data},serializedInstructions,serializedLength)}return serializedInstructions.slice(0,serializedLength)}serializeAddressTableLookups(){let serializedLength=0;const serializedAddressTableLookups=new Uint8Array(PACKET_DATA_SIZE);for(const lookup of this.addressTableLookups){const encodedWritableIndexesLength=Array();encodeLength(encodedWritableIndexesLength,lookup.writableIndexes.length);const encodedReadonlyIndexesLength=Array();encodeLength(encodedReadonlyIndexesLength,lookup.readonlyIndexes.length);const addressTableLookupLayout=LayoutExports.struct([publicKey("accountKey"),LayoutExports.blob(encodedWritableIndexesLength.length,"encodedWritableIndexesLength"),LayoutExports.seq(LayoutExports.u8(),lookup.writableIndexes.length,"writableIndexes"),LayoutExports.blob(encodedReadonlyIndexesLength.length,"encodedReadonlyIndexesLength"),LayoutExports.seq(LayoutExports.u8(),lookup.readonlyIndexes.length,"readonlyIndexes")]);serializedLength+=addressTableLookupLayout.encode({accountKey:lookup.accountKey.toBytes(),encodedWritableIndexesLength:new Uint8Array(encodedWritableIndexesLength),writableIndexes:lookup.writableIndexes,encodedReadonlyIndexesLength:new Uint8Array(encodedReadonlyIndexesLength),readonlyIndexes:lookup.readonlyIndexes},serializedAddressTableLookups,serializedLength)}return serializedAddressTableLookups.slice(0,serializedLength)}static deserialize(serializedMessage){let byteArray=[...serializedMessage];const prefix=guardedShift(byteArray);const maskedPrefix=prefix&VERSION_PREFIX_MASK;assert$1(prefix!==maskedPrefix,`Expected versioned message but received legacy message`);const version=maskedPrefix;assert$1(version===0,`Expected versioned message with version 0 but found version ${version}`);const header={numRequiredSignatures:guardedShift(byteArray),numReadonlySignedAccounts:guardedShift(byteArray),numReadonlyUnsignedAccounts:guardedShift(byteArray)};const staticAccountKeys=[];const staticAccountKeysLength=decodeLength(byteArray);for(let i=0;i{const version=VersionedMessage.deserializeMessageVersion(serializedMessage);if(version==="legacy"){return Message.from(serializedMessage)}if(version===0){return MessageV0.deserialize(serializedMessage)}else{throw new Error(`Transaction message version ${version} deserialization is not supported`)}}};let TransactionStatus=function(TransactionStatus){TransactionStatus[TransactionStatus["BLOCKHEIGHT_EXCEEDED"]=0]="BLOCKHEIGHT_EXCEEDED";TransactionStatus[TransactionStatus["PROCESSED"]=1]="PROCESSED";TransactionStatus[TransactionStatus["TIMED_OUT"]=2]="TIMED_OUT";TransactionStatus[TransactionStatus["NONCE_INVALID"]=3]="NONCE_INVALID";return TransactionStatus}({});const DEFAULT_SIGNATURE=bufferExports.Buffer.alloc(SIGNATURE_LENGTH_IN_BYTES).fill(0);class TransactionInstruction{constructor(opts){this.keys=void 0;this.programId=void 0;this.data=bufferExports.Buffer.alloc(0);this.programId=opts.programId;this.keys=opts.keys;if(opts.data){this.data=opts.data}}toJSON(){return{keys:this.keys.map((({pubkey:pubkey,isSigner:isSigner,isWritable:isWritable})=>({pubkey:pubkey.toJSON(),isSigner:isSigner,isWritable:isWritable}))),programId:this.programId.toJSON(),data:[...this.data]}}}class Transaction{get signature(){if(this.signatures.length>0){return this.signatures[0].signature}return null}constructor(opts){this.signatures=[];this.feePayer=void 0;this.instructions=[];this.recentBlockhash=void 0;this.lastValidBlockHeight=void 0;this.nonceInfo=void 0;this.minNonceContextSlot=void 0;this._message=void 0;this._json=void 0;if(!opts){return}if(opts.feePayer){this.feePayer=opts.feePayer}if(opts.signatures){this.signatures=opts.signatures}if(Object.prototype.hasOwnProperty.call(opts,"nonceInfo")){const{minContextSlot:minContextSlot,nonceInfo:nonceInfo}=opts;this.minNonceContextSlot=minContextSlot;this.nonceInfo=nonceInfo}else if(Object.prototype.hasOwnProperty.call(opts,"lastValidBlockHeight")){const{blockhash:blockhash,lastValidBlockHeight:lastValidBlockHeight}=opts;this.recentBlockhash=blockhash;this.lastValidBlockHeight=lastValidBlockHeight}else{const{recentBlockhash:recentBlockhash,nonceInfo:nonceInfo}=opts;if(nonceInfo){this.nonceInfo=nonceInfo}this.recentBlockhash=recentBlockhash}}toJSON(){return{recentBlockhash:this.recentBlockhash||null,feePayer:this.feePayer?this.feePayer.toJSON():null,nonceInfo:this.nonceInfo?{nonce:this.nonceInfo.nonce,nonceInstruction:this.nonceInfo.nonceInstruction.toJSON()}:null,instructions:this.instructions.map((instruction=>instruction.toJSON())),signers:this.signatures.map((({publicKey:publicKey})=>publicKey.toJSON()))}}add(...items){if(items.length===0){throw new Error("No instructions")}items.forEach((item=>{if("instructions"in item){this.instructions=this.instructions.concat(item.instructions)}else if("data"in item&&"programId"in item&&"keys"in item){this.instructions.push(item)}else{this.instructions.push(new TransactionInstruction(item))}}));return this}compileMessage(){if(this._message&&JSON.stringify(this.toJSON())===JSON.stringify(this._json)){return this._message}let recentBlockhash;let instructions;if(this.nonceInfo){recentBlockhash=this.nonceInfo.nonce;if(this.instructions[0]!=this.nonceInfo.nonceInstruction){instructions=[this.nonceInfo.nonceInstruction,...this.instructions]}else{instructions=this.instructions}}else{recentBlockhash=this.recentBlockhash;instructions=this.instructions}if(!recentBlockhash){throw new Error("Transaction recentBlockhash required")}if(instructions.length<1){console.warn("No instructions provided")}let feePayer;if(this.feePayer){feePayer=this.feePayer}else if(this.signatures.length>0&&this.signatures[0].publicKey){feePayer=this.signatures[0].publicKey}else{throw new Error("Transaction fee payer required")}for(let i=0;i{instruction.keys.forEach((accountMeta=>{accountMetas.push({...accountMeta})}));const programId=instruction.programId.toString();if(!programIds.includes(programId)){programIds.push(programId)}}));programIds.forEach((programId=>{accountMetas.push({pubkey:new PublicKey(programId),isSigner:false,isWritable:false})}));const uniqueMetas=[];accountMetas.forEach((accountMeta=>{const pubkeyString=accountMeta.pubkey.toString();const uniqueIndex=uniqueMetas.findIndex((x=>x.pubkey.toString()===pubkeyString));if(uniqueIndex>-1){uniqueMetas[uniqueIndex].isWritable=uniqueMetas[uniqueIndex].isWritable||accountMeta.isWritable;uniqueMetas[uniqueIndex].isSigner=uniqueMetas[uniqueIndex].isSigner||accountMeta.isSigner}else{uniqueMetas.push(accountMeta)}}));uniqueMetas.sort((function(x,y){if(x.isSigner!==y.isSigner){return x.isSigner?-1:1}if(x.isWritable!==y.isWritable){return x.isWritable?-1:1}const options={localeMatcher:"best fit",usage:"sort",sensitivity:"variant",ignorePunctuation:false,numeric:false,caseFirst:"lower"};return x.pubkey.toBase58().localeCompare(y.pubkey.toBase58(),"en",options)}));const feePayerIndex=uniqueMetas.findIndex((x=>x.pubkey.equals(feePayer)));if(feePayerIndex>-1){const[payerMeta]=uniqueMetas.splice(feePayerIndex,1);payerMeta.isSigner=true;payerMeta.isWritable=true;uniqueMetas.unshift(payerMeta)}else{uniqueMetas.unshift({pubkey:feePayer,isSigner:true,isWritable:true})}for(const signature of this.signatures){const uniqueIndex=uniqueMetas.findIndex((x=>x.pubkey.equals(signature.publicKey)));if(uniqueIndex>-1){if(!uniqueMetas[uniqueIndex].isSigner){uniqueMetas[uniqueIndex].isSigner=true;console.warn("Transaction references a signature that is unnecessary, "+"only the fee payer and instruction signer accounts should sign a transaction. "+"This behavior is deprecated and will throw an error in the next major version release.")}}else{throw new Error(`unknown signer: ${signature.publicKey.toString()}`)}}let numRequiredSignatures=0;let numReadonlySignedAccounts=0;let numReadonlyUnsignedAccounts=0;const signedKeys=[];const unsignedKeys=[];uniqueMetas.forEach((({pubkey:pubkey,isSigner:isSigner,isWritable:isWritable})=>{if(isSigner){signedKeys.push(pubkey.toString());numRequiredSignatures+=1;if(!isWritable){numReadonlySignedAccounts+=1}}else{unsignedKeys.push(pubkey.toString());if(!isWritable){numReadonlyUnsignedAccounts+=1}}}));const accountKeys=signedKeys.concat(unsignedKeys);const compiledInstructions=instructions.map((instruction=>{const{data:data,programId:programId}=instruction;return{programIdIndex:accountKeys.indexOf(programId.toString()),accounts:instruction.keys.map((meta=>accountKeys.indexOf(meta.pubkey.toString()))),data:bs58.encode(data)}}));compiledInstructions.forEach((instruction=>{assert$1(instruction.programIdIndex>=0);instruction.accounts.forEach((keyIndex=>assert$1(keyIndex>=0)))}));return new Message({header:{numRequiredSignatures:numRequiredSignatures,numReadonlySignedAccounts:numReadonlySignedAccounts,numReadonlyUnsignedAccounts:numReadonlyUnsignedAccounts},accountKeys:accountKeys,recentBlockhash:recentBlockhash,instructions:compiledInstructions})}_compile(){const message=this.compileMessage();const signedKeys=message.accountKeys.slice(0,message.header.numRequiredSignatures);if(this.signatures.length===signedKeys.length){const valid=this.signatures.every(((pair,index)=>signedKeys[index].equals(pair.publicKey)));if(valid)return message}this.signatures=signedKeys.map((publicKey=>({signature:null,publicKey:publicKey})));return message}serializeMessage(){return this._compile().serialize()}async getEstimatedFee(connection){return(await connection.getFeeForMessage(this.compileMessage())).value}setSigners(...signers){if(signers.length===0){throw new Error("No signers")}const seen=new Set;this.signatures=signers.filter((publicKey=>{const key=publicKey.toString();if(seen.has(key)){return false}else{seen.add(key);return true}})).map((publicKey=>({signature:null,publicKey:publicKey})))}sign(...signers){if(signers.length===0){throw new Error("No signers")}const seen=new Set;const uniqueSigners=[];for(const signer of signers){const key=signer.publicKey.toString();if(seen.has(key)){continue}else{seen.add(key);uniqueSigners.push(signer)}}this.signatures=uniqueSigners.map((signer=>({signature:null,publicKey:signer.publicKey})));const message=this._compile();this._partialSign(message,...uniqueSigners)}partialSign(...signers){if(signers.length===0){throw new Error("No signers")}const seen=new Set;const uniqueSigners=[];for(const signer of signers){const key=signer.publicKey.toString();if(seen.has(key)){continue}else{seen.add(key);uniqueSigners.push(signer)}}const message=this._compile();this._partialSign(message,...uniqueSigners)}_partialSign(message,...signers){const signData=message.serialize();signers.forEach((signer=>{const signature=sign(signData,signer.secretKey);this._addSignature(signer.publicKey,toBuffer(signature))}))}addSignature(pubkey,signature){this._compile();this._addSignature(pubkey,signature)}_addSignature(pubkey,signature){assert$1(signature.length===64);const index=this.signatures.findIndex((sigpair=>pubkey.equals(sigpair.publicKey)));if(index<0){throw new Error(`unknown signer: ${pubkey.toString()}`)}this.signatures[index].signature=bufferExports.Buffer.from(signature)}verifySignatures(requireAllSignatures=true){const signatureErrors=this._getMessageSignednessErrors(this.serializeMessage(),requireAllSignatures);return!signatureErrors}_getMessageSignednessErrors(message,requireAllSignatures){const errors={};for(const{signature:signature,publicKey:publicKey}of this.signatures){if(signature===null){if(requireAllSignatures){(errors.missing||=[]).push(publicKey)}}else{if(!verify(signature,message,publicKey.toBytes())){(errors.invalid||=[]).push(publicKey)}}}return errors.invalid||errors.missing?errors:undefined}serialize(config){const{requireAllSignatures:requireAllSignatures,verifySignatures:verifySignatures}=Object.assign({requireAllSignatures:true,verifySignatures:true},config);const signData=this.serializeMessage();if(verifySignatures){const sigErrors=this._getMessageSignednessErrors(signData,requireAllSignatures);if(sigErrors){let errorMessage="Signature verification failed.";if(sigErrors.invalid){errorMessage+=`\nInvalid signature for public key${sigErrors.invalid.length===1?"":"(s)"} [\`${sigErrors.invalid.map((p=>p.toBase58())).join("`, `")}\`].`}if(sigErrors.missing){errorMessage+=`\nMissing signature for public key${sigErrors.missing.length===1?"":"(s)"} [\`${sigErrors.missing.map((p=>p.toBase58())).join("`, `")}\`].`}throw new Error(errorMessage)}}return this._serialize(signData)}_serialize(signData){const{signatures:signatures}=this;const signatureCount=[];encodeLength(signatureCount,signatures.length);const transactionLength=signatureCount.length+signatures.length*64+signData.length;const wireTransaction=bufferExports.Buffer.alloc(transactionLength);assert$1(signatures.length<256);bufferExports.Buffer.from(signatureCount).copy(wireTransaction,0);signatures.forEach((({signature:signature},index)=>{if(signature!==null){assert$1(signature.length===64,`signature has invalid length`);bufferExports.Buffer.from(signature).copy(wireTransaction,signatureCount.length+index*64)}}));signData.copy(wireTransaction,signatureCount.length+signatures.length*64);assert$1(wireTransaction.length<=PACKET_DATA_SIZE,`Transaction too large: ${wireTransaction.length} > ${PACKET_DATA_SIZE}`);return wireTransaction}get keys(){assert$1(this.instructions.length===1);return this.instructions[0].keys.map((keyObj=>keyObj.pubkey))}get programId(){assert$1(this.instructions.length===1);return this.instructions[0].programId}get data(){assert$1(this.instructions.length===1);return this.instructions[0].data}static from(buffer){let byteArray=[...buffer];const signatureCount=decodeLength(byteArray);let signatures=[];for(let i=0;i0){transaction.feePayer=message.accountKeys[0]}signatures.forEach(((signature,index)=>{const sigPubkeyPair={signature:signature==bs58.encode(DEFAULT_SIGNATURE)?null:bs58.decode(signature),publicKey:message.accountKeys[index]};transaction.signatures.push(sigPubkeyPair)}));message.instructions.forEach((instruction=>{const keys=instruction.accounts.map((account=>{const pubkey=message.accountKeys[account];return{pubkey:pubkey,isSigner:transaction.signatures.some((keyObj=>keyObj.publicKey.toString()===pubkey.toString()))||message.isAccountSigner(account),isWritable:message.isAccountWritable(account)}}));transaction.instructions.push(new TransactionInstruction({keys:keys,programId:message.accountKeys[instruction.programIdIndex],data:bs58.decode(instruction.data)}))}));transaction._message=message;transaction._json=transaction.toJSON();return transaction}}class TransactionMessage{constructor(args){this.payerKey=void 0;this.instructions=void 0;this.recentBlockhash=void 0;this.payerKey=args.payerKey;this.instructions=args.instructions;this.recentBlockhash=args.recentBlockhash}static decompile(message,args){const{header:header,compiledInstructions:compiledInstructions,recentBlockhash:recentBlockhash}=message;const{numRequiredSignatures:numRequiredSignatures,numReadonlySignedAccounts:numReadonlySignedAccounts,numReadonlyUnsignedAccounts:numReadonlyUnsignedAccounts}=header;const numWritableSignedAccounts=numRequiredSignatures-numReadonlySignedAccounts;assert$1(numWritableSignedAccounts>0,"Message header is invalid");const numWritableUnsignedAccounts=message.staticAccountKeys.length-numRequiredSignatures-numReadonlyUnsignedAccounts;assert$1(numWritableUnsignedAccounts>=0,"Message header is invalid");const accountKeys=message.getAccountKeys(args);const payerKey=accountKeys.get(0);if(payerKey===undefined){throw new Error("Failed to decompile message because no account keys were found")}const instructions=[];for(const compiledIx of compiledInstructions){const keys=[];for(const keyIndex of compiledIx.accountKeyIndexes){const pubkey=accountKeys.get(keyIndex);if(pubkey===undefined){throw new Error(`Failed to find key for account key index ${keyIndex}`)}const isSigner=keyIndexpubkey.equals(signer.publicKey)));assert$1(signerIndex>=0,`Cannot sign with non signer key ${signer.publicKey.toBase58()}`);this.signatures[signerIndex]=sign(messageData,signer.secretKey)}}addSignature(publicKey,signature){assert$1(signature.byteLength===64,"Signature must be 64 bytes long");const signerPubkeys=this.message.staticAccountKeys.slice(0,this.message.header.numRequiredSignatures);const signerIndex=signerPubkeys.findIndex((pubkey=>pubkey.equals(publicKey)));assert$1(signerIndex>=0,`Can not add signature; \`${publicKey.toBase58()}\` is not required to sign this transaction`);this.signatures[signerIndex]=signature}}const NUM_TICKS_PER_SECOND=160;const DEFAULT_TICKS_PER_SLOT=64;const NUM_SLOTS_PER_SECOND=NUM_TICKS_PER_SECOND/DEFAULT_TICKS_PER_SLOT;const MS_PER_SLOT=1e3/NUM_SLOTS_PER_SECOND;const SYSVAR_CLOCK_PUBKEY=new PublicKey("SysvarC1ock11111111111111111111111111111111");const SYSVAR_EPOCH_SCHEDULE_PUBKEY=new PublicKey("SysvarEpochSchedu1e111111111111111111111111");const SYSVAR_INSTRUCTIONS_PUBKEY=new PublicKey("Sysvar1nstructions1111111111111111111111111");const SYSVAR_RECENT_BLOCKHASHES_PUBKEY=new PublicKey("SysvarRecentB1ockHashes11111111111111111111");const SYSVAR_RENT_PUBKEY=new PublicKey("SysvarRent111111111111111111111111111111111");const SYSVAR_REWARDS_PUBKEY=new PublicKey("SysvarRewards111111111111111111111111111111");const SYSVAR_SLOT_HASHES_PUBKEY=new PublicKey("SysvarS1otHashes111111111111111111111111111");const SYSVAR_SLOT_HISTORY_PUBKEY=new PublicKey("SysvarS1otHistory11111111111111111111111111");const SYSVAR_STAKE_HISTORY_PUBKEY=new PublicKey("SysvarStakeHistory1111111111111111111111111");class SendTransactionError extends Error{constructor({action:action,signature:signature,transactionMessage:transactionMessage,logs:logs}){const maybeLogsOutput=logs?`Logs: \n${JSON.stringify(logs.slice(-10),null,2)}. `:"";const guideText="\nCatch the `SendTransactionError` and call `getLogs()` on it for full details.";let message;switch(action){case"send":message=`Transaction ${signature} resulted in an error. \n`+`${transactionMessage}. `+maybeLogsOutput+guideText;break;case"simulate":message=`Simulation failed. \nMessage: ${transactionMessage}. \n`+maybeLogsOutput+guideText;break;default:{message=`Unknown action '${(a=>a)(action)}'`}}super(message);this.signature=void 0;this.transactionMessage=void 0;this.transactionLogs=void 0;this.signature=signature;this.transactionMessage=transactionMessage;this.transactionLogs=logs?logs:undefined}get transactionError(){return{message:this.transactionMessage,logs:Array.isArray(this.transactionLogs)?this.transactionLogs:undefined}}get logs(){const cachedLogs=this.transactionLogs;if(cachedLogs!=null&&typeof cachedLogs==="object"&&"then"in cachedLogs){return undefined}return cachedLogs}async getLogs(connection){if(!Array.isArray(this.transactionLogs)){this.transactionLogs=new Promise(((resolve,reject)=>{connection.getTransaction(this.signature).then((tx=>{if(tx&&tx.meta&&tx.meta.logMessages){const logs=tx.meta.logMessages;this.transactionLogs=logs;resolve(logs)}else{reject(new Error("Log messages not found"))}})).catch(reject)}))}return await this.transactionLogs}}const SolanaJSONRPCErrorCode={JSON_RPC_SERVER_ERROR_BLOCK_CLEANED_UP:-32001,JSON_RPC_SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE:-32002,JSON_RPC_SERVER_ERROR_TRANSACTION_SIGNATURE_VERIFICATION_FAILURE:-32003,JSON_RPC_SERVER_ERROR_BLOCK_NOT_AVAILABLE:-32004,JSON_RPC_SERVER_ERROR_NODE_UNHEALTHY:-32005,JSON_RPC_SERVER_ERROR_TRANSACTION_PRECOMPILE_VERIFICATION_FAILURE:-32006,JSON_RPC_SERVER_ERROR_SLOT_SKIPPED:-32007,JSON_RPC_SERVER_ERROR_NO_SNAPSHOT:-32008,JSON_RPC_SERVER_ERROR_LONG_TERM_STORAGE_SLOT_SKIPPED:-32009,JSON_RPC_SERVER_ERROR_KEY_EXCLUDED_FROM_SECONDARY_INDEX:-32010,JSON_RPC_SERVER_ERROR_TRANSACTION_HISTORY_NOT_AVAILABLE:-32011,JSON_RPC_SCAN_ERROR:-32012,JSON_RPC_SERVER_ERROR_TRANSACTION_SIGNATURE_LEN_MISMATCH:-32013,JSON_RPC_SERVER_ERROR_BLOCK_STATUS_NOT_AVAILABLE_YET:-32014,JSON_RPC_SERVER_ERROR_UNSUPPORTED_TRANSACTION_VERSION:-32015,JSON_RPC_SERVER_ERROR_MIN_CONTEXT_SLOT_NOT_REACHED:-32016};class SolanaJSONRPCError extends Error{constructor({code:code,message:message,data:data},customMessage){super(customMessage!=null?`${customMessage}: ${message}`:message);this.code=void 0;this.data=void 0;this.code=code;this.data=data;this.name="SolanaJSONRPCError"}}async function sendAndConfirmTransaction(connection,transaction,signers,options){const sendOptions=options&&{skipPreflight:options.skipPreflight,preflightCommitment:options.preflightCommitment||options.commitment,maxRetries:options.maxRetries,minContextSlot:options.minContextSlot};const signature=await connection.sendTransaction(transaction,signers,sendOptions);let status;if(transaction.recentBlockhash!=null&&transaction.lastValidBlockHeight!=null){status=(await connection.confirmTransaction({abortSignal:options?.abortSignal,signature:signature,blockhash:transaction.recentBlockhash,lastValidBlockHeight:transaction.lastValidBlockHeight},options&&options.commitment)).value}else if(transaction.minNonceContextSlot!=null&&transaction.nonceInfo!=null){const{nonceInstruction:nonceInstruction}=transaction.nonceInfo;const nonceAccountPubkey=nonceInstruction.keys[0].pubkey;status=(await connection.confirmTransaction({abortSignal:options?.abortSignal,minContextSlot:transaction.minNonceContextSlot,nonceAccountPubkey:nonceAccountPubkey,nonceValue:transaction.nonceInfo.nonce,signature:signature},options&&options.commitment)).value}else{if(options?.abortSignal!=null){console.warn("sendAndConfirmTransaction(): A transaction with a deprecated confirmation strategy was "+"supplied along with an `abortSignal`. Only transactions having `lastValidBlockHeight` "+"or a combination of `nonceInfo` and `minNonceContextSlot` are abortable.")}status=(await connection.confirmTransaction(signature,options&&options.commitment)).value}if(status.err){if(signature!=null){throw new SendTransactionError({action:"send",signature:signature,transactionMessage:`Status: (${JSON.stringify(status)})`})}throw new Error(`Transaction ${signature} failed (${JSON.stringify(status)})`)}return signature}function sleep(ms){return new Promise((resolve=>setTimeout(resolve,ms)))}function encodeData(type,fields){const allocLength=type.layout.span>=0?type.layout.span:getAlloc(type,fields);const data=bufferExports.Buffer.alloc(allocLength);const layoutFields=Object.assign({instruction:type.index},fields);type.layout.encode(layoutFields,data);return data}function decodeData$1(type,buffer){let data;try{data=type.layout.decode(buffer)}catch(err){throw new Error("invalid instruction; "+err)}if(data.instruction!==type.index){throw new Error(`invalid instruction; instruction index mismatch ${data.instruction} != ${type.index}`)}return data}const FeeCalculatorLayout=LayoutExports.nu64("lamportsPerSignature");const NonceAccountLayout=LayoutExports.struct([LayoutExports.u32("version"),LayoutExports.u32("state"),publicKey("authorizedPubkey"),publicKey("nonce"),LayoutExports.struct([FeeCalculatorLayout],"feeCalculator")]);const NONCE_ACCOUNT_LENGTH=NonceAccountLayout.span;class NonceAccount{constructor(args){this.authorizedPubkey=void 0;this.nonce=void 0;this.feeCalculator=void 0;this.authorizedPubkey=args.authorizedPubkey;this.nonce=args.nonce;this.feeCalculator=args.feeCalculator}static fromAccountData(buffer){const nonceAccount=NonceAccountLayout.decode(toBuffer(buffer),0);return new NonceAccount({authorizedPubkey:new PublicKey(nonceAccount.authorizedPubkey),nonce:new PublicKey(nonceAccount.nonce).toString(),feeCalculator:nonceAccount.feeCalculator})}}var SOLANA_ERROR__CODECS__CANNOT_DECODE_EMPTY_BYTE_ARRAY=8078e3;var SOLANA_ERROR__CODECS__INVALID_BYTE_LENGTH=8078001;var SOLANA_ERROR__CODECS__ENCODER_DECODER_SIZE_COMPATIBILITY_MISMATCH=8078004;var SOLANA_ERROR__CODECS__ENCODER_DECODER_FIXED_SIZE_MISMATCH=8078005;var SOLANA_ERROR__CODECS__ENCODER_DECODER_MAX_SIZE_MISMATCH=8078006;var SOLANA_ERROR__CODECS__NUMBER_OUT_OF_RANGE=8078011;function encodeValue(value){if(Array.isArray(value)){const commaSeparatedValues=value.map(encodeValue).join("%2C%20");return"%5B"+commaSeparatedValues+"%5D"}else if(typeof value==="bigint"){return`${value}n`}else{return encodeURIComponent(String(value!=null&&Object.getPrototypeOf(value)===null?{...value}:value))}}function encodeObjectContextEntry([key,value]){return`${key}=${encodeValue(value)}`}function encodeContextObject(context){const searchParamsString=Object.entries(context).map(encodeObjectContextEntry).join("&");return btoa(searchParamsString)}function getErrorMessage(code,context={}){{let decodingAdviceMessage=`Solana error #${code}; Decode this error by running \`npx @solana/errors decode -- ${code}`;if(Object.keys(context).length){decodingAdviceMessage+=` '${encodeContextObject(context)}'`}return`${decodingAdviceMessage}\``}}var SolanaError=class extends Error{cause=this.cause;context;constructor(...[code,contextAndErrorOptions]){let context;let errorOptions;if(contextAndErrorOptions){const{cause:cause,...contextRest}=contextAndErrorOptions;if(cause){errorOptions={cause:cause}}if(Object.keys(contextRest).length>0){context=contextRest}}const message=getErrorMessage(code,context);super(message,errorOptions);this.context={__code:code,...context};this.name="SolanaError"}};function getEncodedSize(value,encoder){return"fixedSize"in encoder?encoder.fixedSize:encoder.getSizeFromValue(value)}function createEncoder(encoder){return Object.freeze({...encoder,encode:value=>{const bytes=new Uint8Array(getEncodedSize(value,encoder));encoder.write(value,bytes,0);return bytes}})}function createDecoder(decoder){return Object.freeze({...decoder,decode:(bytes,offset=0)=>decoder.read(bytes,offset)[0]})}function isFixedSize(codec){return"fixedSize"in codec&&typeof codec.fixedSize==="number"}function combineCodec(encoder,decoder){if(isFixedSize(encoder)!==isFixedSize(decoder)){throw new SolanaError(SOLANA_ERROR__CODECS__ENCODER_DECODER_SIZE_COMPATIBILITY_MISMATCH)}if(isFixedSize(encoder)&&isFixedSize(decoder)&&encoder.fixedSize!==decoder.fixedSize){throw new SolanaError(SOLANA_ERROR__CODECS__ENCODER_DECODER_FIXED_SIZE_MISMATCH,{decoderFixedSize:decoder.fixedSize,encoderFixedSize:encoder.fixedSize})}if(!isFixedSize(encoder)&&!isFixedSize(decoder)&&encoder.maxSize!==decoder.maxSize){throw new SolanaError(SOLANA_ERROR__CODECS__ENCODER_DECODER_MAX_SIZE_MISMATCH,{decoderMaxSize:decoder.maxSize,encoderMaxSize:encoder.maxSize})}return{...decoder,...encoder,decode:decoder.decode,encode:encoder.encode,read:decoder.read,write:encoder.write}}function assertByteArrayIsNotEmptyForCodec(codecDescription,bytes,offset=0){if(bytes.length-offset<=0){throw new SolanaError(SOLANA_ERROR__CODECS__CANNOT_DECODE_EMPTY_BYTE_ARRAY,{codecDescription:codecDescription})}}function assertByteArrayHasEnoughBytesForCodec(codecDescription,expected,bytes,offset=0){const bytesLength=bytes.length-offset;if(bytesLengthmax){throw new SolanaError(SOLANA_ERROR__CODECS__NUMBER_OUT_OF_RANGE,{codecDescription:codecDescription,max:max,min:min,value:value})}}function isLittleEndian(config){return config?.endian===1?false:true}function numberEncoderFactory(input){return createEncoder({fixedSize:input.size,write(value,bytes,offset){if(input.range){assertNumberIsBetweenForCodec(input.name,input.range[0],input.range[1],value)}const arrayBuffer=new ArrayBuffer(input.size);input.set(new DataView(arrayBuffer),value,isLittleEndian(input.config));bytes.set(new Uint8Array(arrayBuffer),offset);return offset+input.size}})}function numberDecoderFactory(input){return createDecoder({fixedSize:input.size,read(bytes,offset=0){assertByteArrayIsNotEmptyForCodec(input.name,bytes,offset);assertByteArrayHasEnoughBytesForCodec(input.name,input.size,bytes,offset);const view=new DataView(toArrayBuffer(bytes,offset,input.size));return[input.get(view,isLittleEndian(input.config)),offset+input.size]}})}function toArrayBuffer(bytes,offset,length){const bytesOffset=bytes.byteOffset+(offset??0);const bytesLength=length??bytes.byteLength;return bytes.buffer.slice(bytesOffset,bytesOffset+bytesLength)}var getU64Encoder=(config={})=>numberEncoderFactory({config:config,name:"u64",range:[0n,BigInt("0xffffffffffffffff")],set:(view,value,le)=>view.setBigUint64(0,BigInt(value),le),size:8});var getU64Decoder=(config={})=>numberDecoderFactory({config:config,get:(view,le)=>view.getBigUint64(0,le),name:"u64",size:8});var getU64Codec=(config={})=>combineCodec(getU64Encoder(config),getU64Decoder(config));function u64(property){const layout=LayoutExports.blob(8,property);const decode=layout.decode.bind(layout);const encode=layout.encode.bind(layout);const bigIntLayout=layout;const codec=getU64Codec();bigIntLayout.decode=(buffer,offset)=>{const src=decode(buffer,offset);return codec.decode(src)};bigIntLayout.encode=(bigInt,buffer,offset)=>{const src=codec.encode(bigInt);return encode(src,buffer,offset)};return bigIntLayout}class SystemInstruction{constructor(){}static decodeInstructionType(instruction){this.checkProgramId(instruction.programId);const instructionTypeLayout=LayoutExports.u32("instruction");const typeIndex=instructionTypeLayout.decode(instruction.data);let type;for(const[ixType,layout]of Object.entries(SYSTEM_INSTRUCTION_LAYOUTS)){if(layout.index==typeIndex){type=ixType;break}}if(!type){throw new Error("Instruction type incorrect; not a SystemInstruction")}return type}static decodeCreateAccount(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,2);const{lamports:lamports,space:space,programId:programId}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.Create,instruction.data);return{fromPubkey:instruction.keys[0].pubkey,newAccountPubkey:instruction.keys[1].pubkey,lamports:lamports,space:space,programId:new PublicKey(programId)}}static decodeTransfer(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,2);const{lamports:lamports}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.Transfer,instruction.data);return{fromPubkey:instruction.keys[0].pubkey,toPubkey:instruction.keys[1].pubkey,lamports:lamports}}static decodeTransferWithSeed(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);const{lamports:lamports,seed:seed,programId:programId}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.TransferWithSeed,instruction.data);return{fromPubkey:instruction.keys[0].pubkey,basePubkey:instruction.keys[1].pubkey,toPubkey:instruction.keys[2].pubkey,lamports:lamports,seed:seed,programId:new PublicKey(programId)}}static decodeAllocate(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,1);const{space:space}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.Allocate,instruction.data);return{accountPubkey:instruction.keys[0].pubkey,space:space}}static decodeAllocateWithSeed(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,1);const{base:base,seed:seed,space:space,programId:programId}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.AllocateWithSeed,instruction.data);return{accountPubkey:instruction.keys[0].pubkey,basePubkey:new PublicKey(base),seed:seed,space:space,programId:new PublicKey(programId)}}static decodeAssign(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,1);const{programId:programId}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.Assign,instruction.data);return{accountPubkey:instruction.keys[0].pubkey,programId:new PublicKey(programId)}}static decodeAssignWithSeed(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,1);const{base:base,seed:seed,programId:programId}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.AssignWithSeed,instruction.data);return{accountPubkey:instruction.keys[0].pubkey,basePubkey:new PublicKey(base),seed:seed,programId:new PublicKey(programId)}}static decodeCreateWithSeed(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,2);const{base:base,seed:seed,lamports:lamports,space:space,programId:programId}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.CreateWithSeed,instruction.data);return{fromPubkey:instruction.keys[0].pubkey,newAccountPubkey:instruction.keys[1].pubkey,basePubkey:new PublicKey(base),seed:seed,lamports:lamports,space:space,programId:new PublicKey(programId)}}static decodeNonceInitialize(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);const{authorized:authorized}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.InitializeNonceAccount,instruction.data);return{noncePubkey:instruction.keys[0].pubkey,authorizedPubkey:new PublicKey(authorized)}}static decodeNonceAdvance(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.AdvanceNonceAccount,instruction.data);return{noncePubkey:instruction.keys[0].pubkey,authorizedPubkey:instruction.keys[2].pubkey}}static decodeNonceWithdraw(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,5);const{lamports:lamports}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.WithdrawNonceAccount,instruction.data);return{noncePubkey:instruction.keys[0].pubkey,toPubkey:instruction.keys[1].pubkey,authorizedPubkey:instruction.keys[4].pubkey,lamports:lamports}}static decodeNonceAuthorize(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,2);const{authorized:authorized}=decodeData$1(SYSTEM_INSTRUCTION_LAYOUTS.AuthorizeNonceAccount,instruction.data);return{noncePubkey:instruction.keys[0].pubkey,authorizedPubkey:instruction.keys[1].pubkey,newAuthorizedPubkey:new PublicKey(authorized)}}static checkProgramId(programId){if(!programId.equals(SystemProgram.programId)){throw new Error("invalid instruction; programId is not SystemProgram")}}static checkKeyLength(keys,expectedLength){if(keys.length0?balanceNeeded:1,space:data.length,programId:programId}))}if(transaction!==null){await sendAndConfirmTransaction(connection,transaction,[payer,program],{commitment:"confirmed"})}}const dataLayout=LayoutExports.struct([LayoutExports.u32("instruction"),LayoutExports.u32("offset"),LayoutExports.u32("bytesLength"),LayoutExports.u32("bytesLengthPadding"),LayoutExports.seq(LayoutExports.u8("byte"),LayoutExports.offset(LayoutExports.u32(),-8),"bytes")]);const chunkSize=Loader.chunkSize;let offset=0;let array=data;let transactions=[];while(array.length>0){const bytes=array.slice(0,chunkSize);const data=bufferExports.Buffer.alloc(chunkSize+16);dataLayout.encode({instruction:0,offset:offset,bytes:bytes,bytesLength:0,bytesLengthPadding:0},data);const transaction=(new Transaction).add({keys:[{pubkey:program.publicKey,isSigner:true,isWritable:true}],programId:programId,data:data});transactions.push(sendAndConfirmTransaction(connection,transaction,[payer,program],{commitment:"confirmed"}));if(connection._rpcEndpoint.includes("solana.com")){const REQUESTS_PER_SECOND=4;await sleep(1e3/REQUESTS_PER_SECOND)}offset+=chunkSize;array=array.slice(chunkSize)}await Promise.all(transactions);{const dataLayout=LayoutExports.struct([LayoutExports.u32("instruction")]);const data=bufferExports.Buffer.alloc(dataLayout.span);dataLayout.encode({instruction:1},data);const transaction=(new Transaction).add({keys:[{pubkey:program.publicKey,isSigner:true,isWritable:true},{pubkey:SYSVAR_RENT_PUBKEY,isSigner:false,isWritable:false}],programId:programId,data:data});const deployCommitment="processed";const finalizeSignature=await connection.sendTransaction(transaction,[payer,program],{preflightCommitment:deployCommitment});const{context:context,value:value}=await connection.confirmTransaction({signature:finalizeSignature,lastValidBlockHeight:transaction.lastValidBlockHeight,blockhash:transaction.recentBlockhash},deployCommitment);if(value.err){throw new Error(`Transaction ${finalizeSignature} failed (${JSON.stringify(value)})`)}while(true){try{const currentSlot=await connection.getSlot({commitment:deployCommitment});if(currentSlot>context.slot){break}}catch{}await new Promise((resolve=>setTimeout(resolve,Math.round(MS_PER_SLOT/2))))}}return true}}Loader.chunkSize=CHUNK_SIZE;const BPF_LOADER_PROGRAM_ID=new PublicKey("BPFLoader2111111111111111111111111111111111");class BpfLoader{static getMinNumSignatures(dataLength){return Loader.getMinNumSignatures(dataLength)}static load(connection,payer,program,elf,loaderProgramId){return Loader.load(connection,payer,program,loaderProgramId,elf)}}var fastStableStringify$1;var hasRequiredFastStableStringify;function requireFastStableStringify(){if(hasRequiredFastStableStringify)return fastStableStringify$1;hasRequiredFastStableStringify=1;var objToString=Object.prototype.toString;var objKeys=Object.keys||function(obj){var keys=[];for(var name in obj){keys.push(name)}return keys};function stringify(val,isArrayProp){var i,max,str,keys,key,propVal,toStr;if(val===true){return"true"}if(val===false){return"false"}switch(typeof val){case"object":if(val===null){return null}else if(val.toJSON&&typeof val.toJSON==="function"){return stringify(val.toJSON(),isArrayProp)}else{toStr=objToString.call(val);if(toStr==="[object Array]"){str="[";max=val.length-1;for(i=0;i-1){str+=stringify(val[i],true)}return str+"]"}else if(toStr==="[object Object]"){keys=objKeys(val).sort();max=keys.length;str="";i=0;while(icached??(cached=[failure,...failures()])}}function isIterable(x){return isObject(x)&&typeof x[Symbol.iterator]==="function"}function isObject(x){return typeof x==="object"&&x!=null}function isNonArrayObject(x){return isObject(x)&&!Array.isArray(x)}function print(value){if(typeof value==="symbol"){return value.toString()}return typeof value==="string"?JSON.stringify(value):`${value}`}function shiftIterator(input){const{done:done,value:value}=input.next();return done?undefined:value}function toFailure(result,context,struct,value){if(result===true){return}else if(result===false){result={}}else if(typeof result==="string"){result={message:result}}const{path:path,branch:branch}=context;const{type:type}=struct;const{refinement:refinement,message:message=`Expected a value of type \`${type}\`${refinement?` with refinement \`${refinement}\``:""}, but received: \`${print(value)}\``}=result;return{value:value,type:type,refinement:refinement,key:path[path.length-1],path:path,branch:branch,...result,message:message}}function*toFailures(result,context,struct,value){if(!isIterable(result)){result=[result]}for(const r of result){const failure=toFailure(r,context,struct,value);if(failure){yield failure}}}function*run(value,struct,options={}){const{path:path=[],branch:branch=[value],coerce:coerce=false,mask:mask=false}=options;const ctx={path:path,branch:branch,mask:mask};if(coerce){value=struct.coercer(value,ctx)}let status="valid";for(const failure of struct.validator(value,ctx)){failure.explanation=options.message;status="not_valid";yield[failure,undefined]}for(let[k,v,s]of struct.entries(value,ctx)){const ts=run(v,s,{path:k===undefined?path:[...path,k],branch:k===undefined?branch:[...branch,v],coerce:coerce,mask:mask,message:options.message});for(const t of ts){if(t[0]){status=t[0].refinement!=null?"not_refined":"not_valid";yield[t[0],undefined]}else if(coerce){v=t[1];if(k===undefined){value=v}else if(value instanceof Map){value.set(k,v)}else if(value instanceof Set){value.add(v)}else if(isObject(value)){if(v!==undefined||k in value)value[k]=v}}}}if(status!=="not_valid"){for(const failure of struct.refiner(value,ctx)){failure.explanation=options.message;status="not_refined";yield[failure,undefined]}}if(status==="valid"){yield[undefined,value]}}class Struct{constructor(props){const{type:type,schema:schema,validator:validator,refiner:refiner,coercer:coercer=(value=>value),entries:entries=function*(){}}=props;this.type=type;this.schema=schema;this.entries=entries;this.coercer=coercer;if(validator){this.validator=(value,context)=>{const result=validator(value,context);return toFailures(result,context,this,value)}}else{this.validator=()=>[]}if(refiner){this.refiner=(value,context)=>{const result=refiner(value,context);return toFailures(result,context,this,value)}}else{this.refiner=()=>[]}}assert(value,message){return assert(value,this,message)}create(value,message){return create(value,this,message)}is(value){return is(value,this)}mask(value,message){return mask(value,this,message)}validate(value,options={}){return validate$1(value,this,options)}}function assert(value,struct,message){const result=validate$1(value,struct,{message:message});if(result[0]){throw result[0]}}function create(value,struct,message){const result=validate$1(value,struct,{coerce:true,message:message});if(result[0]){throw result[0]}else{return result[1]}}function mask(value,struct,message){const result=validate$1(value,struct,{coerce:true,mask:true,message:message});if(result[0]){throw result[0]}else{return result[1]}}function is(value,struct){const result=validate$1(value,struct);return!result[0]}function validate$1(value,struct,options={}){const tuples=run(value,struct,options);const tuple=shiftIterator(tuples);if(tuple[0]){const error=new StructError(tuple[0],(function*(){for(const t of tuples){if(t[0]){yield t[0]}}}));return[error,undefined]}else{const v=tuple[1];return[undefined,v]}}function define(name,validator){return new Struct({type:name,schema:null,validator:validator})}function any(){return define("any",(()=>true))}function array(Element){return new Struct({type:"array",schema:Element,*entries(value){if(Element&&Array.isArray(value)){for(const[i,v]of value.entries()){yield[i,v,Element]}}},coercer(value){return Array.isArray(value)?value.slice():value},validator(value){return Array.isArray(value)||`Expected an array value, but received: ${print(value)}`}})}function boolean(){return define("boolean",(value=>typeof value==="boolean"))}function instance(Class){return define("instance",(value=>value instanceof Class||`Expected a \`${Class.name}\` instance, but received: ${print(value)}`))}function literal(constant){const description=print(constant);const t=typeof constant;return new Struct({type:"literal",schema:t==="string"||t==="number"||t==="boolean"?constant:null,validator(value){return value===constant||`Expected the literal \`${description}\`, but received: ${print(value)}`}})}function never(){return define("never",(()=>false))}function nullable(struct){return new Struct({...struct,validator:(value,ctx)=>value===null||struct.validator(value,ctx),refiner:(value,ctx)=>value===null||struct.refiner(value,ctx)})}function number(){return define("number",(value=>typeof value==="number"&&!isNaN(value)||`Expected a number, but received: ${print(value)}`))}function optional(struct){return new Struct({...struct,validator:(value,ctx)=>value===undefined||struct.validator(value,ctx),refiner:(value,ctx)=>value===undefined||struct.refiner(value,ctx)})}function record(Key,Value){return new Struct({type:"record",schema:null,*entries(value){if(isObject(value)){for(const k in value){const v=value[k];yield[k,k,Key];yield[k,v,Value]}}},validator(value){return isNonArrayObject(value)||`Expected an object, but received: ${print(value)}`},coercer(value){return isNonArrayObject(value)?{...value}:value}})}function string(){return define("string",(value=>typeof value==="string"||`Expected a string, but received: ${print(value)}`))}function tuple(Structs){const Never=never();return new Struct({type:"tuple",schema:null,*entries(value){if(Array.isArray(value)){const length=Math.max(Structs.length,value.length);for(let i=0;is.type)).join(" | ");return new Struct({type:"union",schema:null,coercer(value,ctx){for(const S of Structs){const[error,coerced]=S.validate(value,{coerce:true,mask:ctx.mask});if(!error){return coerced}}return value},validator(value,ctx){const failures=[];for(const S of Structs){const[...tuples]=run(value,S,ctx);const[first]=tuples;if(!first[0]){return[]}else{for(const[failure]of tuples){if(failure){failures.push(failure)}}}}return[`Expected the value to satisfy a union of \`${description}\`, but received: ${print(value)}`,...failures]}})}function unknown(){return define("unknown",(()=>true))}function coerce(struct,condition,coercer){return new Struct({...struct,coercer:(value,ctx)=>is(value,condition)?struct.coercer(coercer(value,ctx),ctx):struct.coercer(value,ctx)})}var getRandomValues;var rnds8=new Uint8Array(16);function rng(){if(!getRandomValues){getRandomValues=typeof crypto!=="undefined"&&crypto.getRandomValues&&crypto.getRandomValues.bind(crypto)||typeof msCrypto!=="undefined"&&typeof msCrypto.getRandomValues==="function"&&msCrypto.getRandomValues.bind(msCrypto);if(!getRandomValues){throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported")}}return getRandomValues(rnds8)}var REGEX=/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;function validate(uuid){return typeof uuid==="string"&®EX.test(uuid)}var byteToHex=[];for(var i=0;i<256;++i){byteToHex.push((i+256).toString(16).substr(1))}function stringify(arr){var offset=arguments.length>1&&arguments[1]!==undefined?arguments[1]:0;var uuid=(byteToHex[arr[offset+0]]+byteToHex[arr[offset+1]]+byteToHex[arr[offset+2]]+byteToHex[arr[offset+3]]+"-"+byteToHex[arr[offset+4]]+byteToHex[arr[offset+5]]+"-"+byteToHex[arr[offset+6]]+byteToHex[arr[offset+7]]+"-"+byteToHex[arr[offset+8]]+byteToHex[arr[offset+9]]+"-"+byteToHex[arr[offset+10]]+byteToHex[arr[offset+11]]+byteToHex[arr[offset+12]]+byteToHex[arr[offset+13]]+byteToHex[arr[offset+14]]+byteToHex[arr[offset+15]]).toLowerCase();if(!validate(uuid)){throw TypeError("Stringified UUID is invalid")}return uuid}var _nodeId;var _clockseq;var _lastMSecs=0;var _lastNSecs=0;function v1(options,buf,offset){var i=buf&&offset||0;var b=buf||new Array(16);options=options||{};var node=options.node||_nodeId;var clockseq=options.clockseq!==undefined?options.clockseq:_clockseq;if(node==null||clockseq==null){var seedBytes=options.random||(options.rng||rng)();if(node==null){node=_nodeId=[seedBytes[0]|1,seedBytes[1],seedBytes[2],seedBytes[3],seedBytes[4],seedBytes[5]]}if(clockseq==null){clockseq=_clockseq=(seedBytes[6]<<8|seedBytes[7])&16383}}var msecs=options.msecs!==undefined?options.msecs:Date.now();var nsecs=options.nsecs!==undefined?options.nsecs:_lastNSecs+1;var dt=msecs-_lastMSecs+(nsecs-_lastNSecs)/1e4;if(dt<0&&options.clockseq===undefined){clockseq=clockseq+1&16383}if((dt<0||msecs>_lastMSecs)&&options.nsecs===undefined){nsecs=0}if(nsecs>=1e4){throw new Error("uuid.v1(): Can't create more than 10M uuids/sec")}_lastMSecs=msecs;_lastNSecs=nsecs;_clockseq=clockseq;msecs+=122192928e5;var tl=((msecs&268435455)*1e4+nsecs)%4294967296;b[i++]=tl>>>24&255;b[i++]=tl>>>16&255;b[i++]=tl>>>8&255;b[i++]=tl&255;var tmh=msecs/4294967296*1e4&268435455;b[i++]=tmh>>>8&255;b[i++]=tmh&255;b[i++]=tmh>>>24&15|16;b[i++]=tmh>>>16&255;b[i++]=clockseq>>>8|128;b[i++]=clockseq&255;for(var n=0;n<6;++n){b[i+n]=node[n]}return buf||stringify(b)}function parse(uuid){if(!validate(uuid)){throw TypeError("Invalid UUID")}var v;var arr=new Uint8Array(16);arr[0]=(v=parseInt(uuid.slice(0,8),16))>>>24;arr[1]=v>>>16&255;arr[2]=v>>>8&255;arr[3]=v&255;arr[4]=(v=parseInt(uuid.slice(9,13),16))>>>8;arr[5]=v&255;arr[6]=(v=parseInt(uuid.slice(14,18),16))>>>8;arr[7]=v&255;arr[8]=(v=parseInt(uuid.slice(19,23),16))>>>8;arr[9]=v&255;arr[10]=(v=parseInt(uuid.slice(24,36),16))/1099511627776&255;arr[11]=v/4294967296&255;arr[12]=v>>>24&255;arr[13]=v>>>16&255;arr[14]=v>>>8&255;arr[15]=v&255;return arr}function stringToBytes(str){str=unescape(encodeURIComponent(str));var bytes=[];for(var i=0;i>5]>>>i%32&255;var hex=parseInt(hexTab.charAt(x>>>4&15)+hexTab.charAt(x&15),16);output.push(hex)}return output}function getOutputLength(inputLength8){return(inputLength8+64>>>9<<4)+14+1}function wordsToMd5(x,len){x[len>>5]|=128<>5]|=(input[i/8]&255)<>16)+(y>>16)+(lsw>>16);return msw<<16|lsw&65535}function bitRotateLeft(num,cnt){return num<>>32-cnt}function md5cmn(q,a,b,x,s,t){return safeAdd(bitRotateLeft(safeAdd(safeAdd(a,q),safeAdd(x,t)),s),b)}function md5ff(a,b,c,d,x,s,t){return md5cmn(b&c|~b&d,a,b,x,s,t)}function md5gg(a,b,c,d,x,s,t){return md5cmn(b&d|c&~d,a,b,x,s,t)}function md5hh(a,b,c,d,x,s,t){return md5cmn(b^c^d,a,b,x,s,t)}function md5ii(a,b,c,d,x,s,t){return md5cmn(c^(b|~d),a,b,x,s,t)}var v3=v35("v3",48,md5);function v4(options,buf,offset){options=options||{};var rnds=options.random||(options.rng||rng)();rnds[6]=rnds[6]&15|64;rnds[8]=rnds[8]&63|128;if(buf){offset=offset||0;for(var i=0;i<16;++i){buf[offset+i]=rnds[i]}return buf}return stringify(rnds)}function f(s,x,y,z){switch(s){case 0:return x&y^~x&z;case 1:return x^y^z;case 2:return x&y^x&z^y&z;case 3:return x^y^z}}function ROTL(x,n){return x<>>32-n}function sha1(bytes){var K=[1518500249,1859775393,2400959708,3395469782];var H=[1732584193,4023233417,2562383102,271733878,3285377520];if(typeof bytes==="string"){var msg=unescape(encodeURIComponent(bytes));bytes=[];for(var i=0;i>>0;e=d;d=c;c=ROTL(b,30)>>>0;b=a;a=T}H[0]=H[0]+a>>>0;H[1]=H[1]+b>>>0;H[2]=H[2]+c>>>0;H[3]=H[3]+d>>>0;H[4]=H[4]+e>>>0}return[H[0]>>24&255,H[0]>>16&255,H[0]>>8&255,H[0]&255,H[1]>>24&255,H[1]>>16&255,H[1]>>8&255,H[1]&255,H[2]>>24&255,H[2]>>16&255,H[2]>>8&255,H[2]&255,H[3]>>24&255,H[3]>>16&255,H[3]>>8&255,H[3]&255,H[4]>>24&255,H[4]>>16&255,H[4]>>8&255,H[4]&255]}var v5=v35("v5",80,sha1);var nil="00000000-0000-0000-0000-000000000000";function version(uuid){if(!validate(uuid)){throw TypeError("Invalid UUID")}return parseInt(uuid.substr(14,1),16)}var esmBrowser=Object.freeze({__proto__:null,NIL:nil,parse:parse,stringify:stringify,v1:v1,v3:v3,v4:v4,v5:v5,validate:validate,version:version});var require$$0=getAugmentedNamespace(esmBrowser);var generateRequest_1;var hasRequiredGenerateRequest;function requireGenerateRequest(){if(hasRequiredGenerateRequest)return generateRequest_1;hasRequiredGenerateRequest=1;const uuid=require$$0.v4;const generateRequest=function(method,params,id,options){if(typeof method!=="string"){throw new TypeError(method+" must be a string")}options=options||{};const version=typeof options.version==="number"?options.version:2;if(version!==1&&version!==2){throw new TypeError(version+" must be 1 or 2")}const request={method:method};if(version===2){request.jsonrpc="2.0"}if(params){if(typeof params!=="object"&&!Array.isArray(params)){throw new TypeError(params+" must be an object, array or omitted")}request.params=params}if(typeof id==="undefined"){const generator=typeof options.generator==="function"?options.generator:function(){return uuid()};request.id=generator(request,options)}else if(version===2&&id===null){if(options.notificationIdNull){request.id=null}}else{request.id=id}return request};generateRequest_1=generateRequest;return generateRequest_1}var browser;var hasRequiredBrowser;function requireBrowser(){if(hasRequiredBrowser)return browser;hasRequiredBrowser=1;const uuid=require$$0.v4;const generateRequest=requireGenerateRequest();const ClientBrowser=function(callServer,options){if(!(this instanceof ClientBrowser)){return new ClientBrowser(callServer,options)}if(!options){options={}}this.options={reviver:typeof options.reviver!=="undefined"?options.reviver:null,replacer:typeof options.replacer!=="undefined"?options.replacer:null,generator:typeof options.generator!=="undefined"?options.generator:function(){return uuid()},version:typeof options.version!=="undefined"?options.version:2,notificationIdNull:typeof options.notificationIdNull==="boolean"?options.notificationIdNull:false};this.callServer=callServer};browser=ClientBrowser;ClientBrowser.prototype.request=function(method,params,id,callback){const self=this;let request=null;const isBatch=Array.isArray(method)&&typeof params==="function";if(this.options.version===1&&isBatch){throw new TypeError("JSON-RPC 1.0 does not support batching")}const isRaw=!isBatch&&method&&typeof method==="object"&&typeof params==="function";if(isBatch||isRaw){callback=params;request=method}else{if(typeof id==="function"){callback=id;id=undefined}const hasCallback=typeof callback==="function";try{request=generateRequest(method,params,id,{generator:this.options.generator,version:this.options.version,notificationIdNull:this.options.notificationIdNull})}catch(err){if(hasCallback){return callback(err)}throw err}if(!hasCallback){return request}}let message;try{message=JSON.stringify(request,this.options.replacer)}catch(err){return callback(err)}this.callServer(message,(function(err,response){self._parseResponse(err,response,callback)}));return request};ClientBrowser.prototype._parseResponse=function(err,responseText,callback){if(err){callback(err);return}if(!responseText){return callback()}let response;try{response=JSON.parse(responseText,this.options.reviver)}catch(err){return callback(err)}if(callback.length===3){if(Array.isArray(response)){const isError=function(res){return typeof res.error!=="undefined"};const isNotError=function(res){return!isError(res)};return callback(null,response.filter(isError),response.filter(isNotError))}else{return callback(null,response.error,response.result)}}callback(null,response)};return browser}var browserExports=requireBrowser();var RpcClient=getDefaultExportFromCjs(browserExports);const MINIMUM_SLOT_PER_EPOCH=32;function trailingZeros(n){let trailingZeros=0;while(n>1){n/=2;trailingZeros++}return trailingZeros}function nextPowerOfTwo(n){if(n===0)return 1;n--;n|=n>>1;n|=n>>2;n|=n>>4;n|=n>>8;n|=n>>16;n|=n>>32;return n+1}class EpochSchedule{constructor(slotsPerEpoch,leaderScheduleSlotOffset,warmup,firstNormalEpoch,firstNormalSlot){this.slotsPerEpoch=void 0;this.leaderScheduleSlotOffset=void 0;this.warmup=void 0;this.firstNormalEpoch=void 0;this.firstNormalSlot=void 0;this.slotsPerEpoch=slotsPerEpoch;this.leaderScheduleSlotOffset=leaderScheduleSlotOffset;this.warmup=warmup;this.firstNormalEpoch=firstNormalEpoch;this.firstNormalSlot=firstNormalSlot}getEpoch(slot){return this.getEpochAndSlotIndex(slot)[0]}getEpochAndSlotIndex(slot){if(slotthis.emit("open");this.socket.onmessage=event=>this.emit("message",event.data);this.socket.onerror=error=>this.emit("error",error);this.socket.onclose=event=>{this.emit("close",event.code,event.reason)}}send(data,optionsOrCallback,callback){const cb=callback||optionsOrCallback;try{this.socket.send(data);cb()}catch(error){cb(error)}}close(code,reason){this.socket.close(code,reason)}addEventListener(type,listener,options){this.socket.addEventListener(type,listener,options)}};function WebSocket(address,options){return new WebSocketBrowserImpl(address,options)}var DefaultDataPack=class{encode(value){return JSON.stringify(value)}decode(value){return JSON.parse(value)}};var CommonClient=class extends EventEmitter{address;rpc_id;queue;options;autoconnect;ready;reconnect;reconnect_timer_id;reconnect_interval;max_reconnects;rest_options;current_reconnects;generate_request_id;socket;webSocketFactory;dataPack;constructor(webSocketFactory,address="ws://localhost:8080",{autoconnect:autoconnect=true,reconnect:reconnect=true,reconnect_interval:reconnect_interval=1e3,max_reconnects:max_reconnects=5,...rest_options}={},generate_request_id,dataPack){super();this.webSocketFactory=webSocketFactory;this.queue={};this.rpc_id=0;this.address=address;this.autoconnect=autoconnect;this.ready=false;this.reconnect=reconnect;this.reconnect_timer_id=void 0;this.reconnect_interval=reconnect_interval;this.max_reconnects=max_reconnects;this.rest_options=rest_options;this.current_reconnects=0;this.generate_request_id=generate_request_id||(()=>++this.rpc_id);if(!dataPack)this.dataPack=new DefaultDataPack;else this.dataPack=dataPack;if(this.autoconnect)this._connect(this.address,{autoconnect:this.autoconnect,reconnect:this.reconnect,reconnect_interval:this.reconnect_interval,max_reconnects:this.max_reconnects,...this.rest_options})}connect(){if(this.socket)return;this._connect(this.address,{autoconnect:this.autoconnect,reconnect:this.reconnect,reconnect_interval:this.reconnect_interval,max_reconnects:this.max_reconnects,...this.rest_options})}call(method,params,timeout,ws_opts){if(!ws_opts&&"object"===typeof timeout){ws_opts=timeout;timeout=null}return new Promise(((resolve,reject)=>{if(!this.ready)return reject(new Error("socket not ready"));const rpc_id=this.generate_request_id(method,params);const message={jsonrpc:"2.0",method:method,params:params||void 0,id:rpc_id};this.socket.send(this.dataPack.encode(message),ws_opts,(error=>{if(error)return reject(error);this.queue[rpc_id]={promise:[resolve,reject]};if(timeout){this.queue[rpc_id].timeout=setTimeout((()=>{delete this.queue[rpc_id];reject(new Error("reply timeout"))}),timeout)}}))}))}async login(params){const resp=await this.call("rpc.login",params);if(!resp)throw new Error("authentication failed");return resp}async listMethods(){return await this.call("__listMethods")}notify(method,params){return new Promise(((resolve,reject)=>{if(!this.ready)return reject(new Error("socket not ready"));const message={jsonrpc:"2.0",method:method,params:params};this.socket.send(this.dataPack.encode(message),(error=>{if(error)return reject(error);resolve()}))}))}async subscribe(event){if(typeof event==="string")event=[event];const result=await this.call("rpc.on",event);if(typeof event==="string"&&result[event]!=="ok")throw new Error("Failed subscribing to an event '"+event+"' with: "+result[event]);return result}async unsubscribe(event){if(typeof event==="string")event=[event];const result=await this.call("rpc.off",event);if(typeof event==="string"&&result[event]!=="ok")throw new Error("Failed unsubscribing from an event with: "+result);return result}close(code,data){this.socket.close(code||1e3,data)}setAutoReconnect(reconnect){this.reconnect=reconnect}setReconnectInterval(interval){this.reconnect_interval=interval}setMaxReconnects(max_reconnects){this.max_reconnects=max_reconnects}_connect(address,options){clearTimeout(this.reconnect_timer_id);this.socket=this.webSocketFactory(address,options);this.socket.addEventListener("open",(()=>{this.ready=true;this.emit("open");this.current_reconnects=0}));this.socket.addEventListener("message",(({data:message})=>{if(message instanceof ArrayBuffer)message=bufferExports.Buffer.from(message).toString();try{message=this.dataPack.decode(message)}catch(error){return}if(message.notification&&this.listeners(message.notification).length){if(!Object.keys(message.params).length)return this.emit(message.notification);const args=[message.notification];if(message.params.constructor===Object)args.push(message.params);else for(let i=0;i{this.emit.apply(this,args)}))}if(!this.queue[message.id]){if(message.method){return Promise.resolve().then((()=>{this.emit(message.method,message?.params)}))}return}if("error"in message==="result"in message)this.queue[message.id].promise[1](new Error('Server response malformed. Response must include either "result" or "error", but not both.'));if(this.queue[message.id].timeout)clearTimeout(this.queue[message.id].timeout);if(message.error)this.queue[message.id].promise[1](message.error);else this.queue[message.id].promise[0](message.result);delete this.queue[message.id]}));this.socket.addEventListener("error",(error=>this.emit("error",error)));this.socket.addEventListener("close",(({code:code,reason:reason})=>{if(this.ready)setTimeout((()=>this.emit("close",code,reason)),0);this.ready=false;this.socket=void 0;if(code===1e3)return;this.current_reconnects++;if(this.reconnect&&(this.max_reconnects>this.current_reconnects||this.max_reconnects===0))this.reconnect_timer_id=setTimeout((()=>this._connect(address,options)),this.reconnect_interval)}))}};class RpcWebSocketClient extends CommonClient{constructor(address,options,generate_request_id){const webSocketFactory=url=>{const rpc=WebSocket(url,{autoconnect:true,max_reconnects:5,reconnect:true,reconnect_interval:1e3,...options});if("socket"in rpc){this.underlyingSocket=rpc.socket}else{this.underlyingSocket=rpc}return rpc};super(webSocketFactory,address,options,generate_request_id);this.underlyingSocket=void 0}call(...args){const readyState=this.underlyingSocket?.readyState;if(readyState===1){return super.call(...args)}return Promise.reject(new Error("Tried to call a JSON-RPC method `"+args[0]+"` but the socket was not `CONNECTING` or `OPEN` (`readyState` was "+readyState+")"))}notify(...args){const readyState=this.underlyingSocket?.readyState;if(readyState===1){return super.notify(...args)}return Promise.reject(new Error("Tried to send a JSON-RPC notification `"+args[0]+"` but the socket was not `CONNECTING` or `OPEN` (`readyState` was "+readyState+")"))}}function decodeData(type,data){let decoded;try{decoded=type.layout.decode(data)}catch(err){throw new Error("invalid instruction; "+err)}if(decoded.typeIndex!==type.index){throw new Error(`invalid account data; account type mismatch ${decoded.typeIndex} != ${type.index}`)}return decoded}const LOOKUP_TABLE_META_SIZE=56;class AddressLookupTableAccount{constructor(args){this.key=void 0;this.state=void 0;this.key=args.key;this.state=args.state}isActive(){const U64_MAX=BigInt("0xffffffffffffffff");return this.state.deactivationSlot===U64_MAX}static deserialize(accountData){const meta=decodeData(LookupTableMetaLayout,accountData);const serializedAddressesLen=accountData.length-LOOKUP_TABLE_META_SIZE;assert$1(serializedAddressesLen>=0,"lookup table is invalid");assert$1(serializedAddressesLen%32===0,"lookup table is invalid");const numSerializedAddresses=serializedAddressesLen/32;const{addresses:addresses}=LayoutExports.struct([LayoutExports.seq(publicKey(),numSerializedAddresses,"addresses")]).decode(accountData.slice(LOOKUP_TABLE_META_SIZE));return{deactivationSlot:meta.deactivationSlot,lastExtendedSlot:meta.lastExtendedSlot,lastExtendedSlotStartIndex:meta.lastExtendedStartIndex,authority:meta.authority.length!==0?new PublicKey(meta.authority[0]):undefined,addresses:addresses.map((address=>new PublicKey(address)))}}}const LookupTableMetaLayout={index:1,layout:LayoutExports.struct([LayoutExports.u32("typeIndex"),u64("deactivationSlot"),LayoutExports.nu64("lastExtendedSlot"),LayoutExports.u8("lastExtendedStartIndex"),LayoutExports.u8(),LayoutExports.seq(publicKey(),LayoutExports.offset(LayoutExports.u8(),-1),"authority")])};const URL_RE=/^[^:]+:\/\/([^:[]+|\[[^\]]+\])(:\d+)?(.*)/i;function makeWebsocketUrl(endpoint){const matches=endpoint.match(URL_RE);if(matches==null){throw TypeError(`Failed to validate endpoint URL \`${endpoint}\``)}const[_,hostish,portWithColon,rest]=matches;const protocol=endpoint.startsWith("https:")?"wss:":"ws:";const startPort=portWithColon==null?null:parseInt(portWithColon.slice(1),10);const websocketPort=startPort==null?"":`:${startPort+1}`;return`${protocol}//${hostish}${websocketPort}${rest}`}const PublicKeyFromString=coerce(instance(PublicKey),string(),(value=>new PublicKey(value)));const RawAccountDataResult=tuple([string(),literal("base64")]);const BufferFromRawAccountData=coerce(instance(bufferExports.Buffer),RawAccountDataResult,(value=>bufferExports.Buffer.from(value[0],"base64")));const BLOCKHASH_CACHE_TIMEOUT_MS=30*1e3;function assertEndpointUrl(putativeUrl){if(/^https?:/.test(putativeUrl)===false){throw new TypeError("Endpoint URL must start with `http:` or `https:`.")}return putativeUrl}function extractCommitmentFromConfig(commitmentOrConfig){let commitment;let config;if(typeof commitmentOrConfig==="string"){commitment=commitmentOrConfig}else if(commitmentOrConfig){const{commitment:specifiedCommitment,...specifiedConfig}=commitmentOrConfig;commitment=specifiedCommitment;config=specifiedConfig}return{commitment:commitment,config:config}}function applyDefaultMemcmpEncodingToFilters(filters){return filters.map((filter=>"memcmp"in filter?{...filter,memcmp:{...filter.memcmp,encoding:filter.memcmp.encoding??"base58"}}:filter))}function createRpcResult(result){return union([type({jsonrpc:literal("2.0"),id:string(),result:result}),type({jsonrpc:literal("2.0"),id:string(),error:type({code:unknown(),message:string(),data:optional(any())})})])}const UnknownRpcResult=createRpcResult(unknown());function jsonRpcResult(schema){return coerce(createRpcResult(schema),UnknownRpcResult,(value=>{if("error"in value){return value}else{return{...value,result:create(value.result,schema)}}}))}function jsonRpcResultAndContext(value){return jsonRpcResult(type({context:type({slot:number()}),value:value}))}function notificationResultAndContext(value){return type({context:type({slot:number()}),value:value})}function versionedMessageFromResponse(version,response){if(version===0){return new MessageV0({header:response.header,staticAccountKeys:response.accountKeys.map((accountKey=>new PublicKey(accountKey))),recentBlockhash:response.recentBlockhash,compiledInstructions:response.instructions.map((ix=>({programIdIndex:ix.programIdIndex,accountKeyIndexes:ix.accounts,data:bs58.decode(ix.data)}))),addressTableLookups:response.addressTableLookups})}else{return new Message(response)}}const GetInflationGovernorResult=type({foundation:number(),foundationTerm:number(),initial:number(),taper:number(),terminal:number()});const GetInflationRewardResult=jsonRpcResult(array(nullable(type({epoch:number(),effectiveSlot:number(),amount:number(),postBalance:number(),commission:optional(nullable(number()))}))));const GetRecentPrioritizationFeesResult=array(type({slot:number(),prioritizationFee:number()}));const GetInflationRateResult=type({total:number(),validator:number(),foundation:number(),epoch:number()});const GetEpochInfoResult=type({epoch:number(),slotIndex:number(),slotsInEpoch:number(),absoluteSlot:number(),blockHeight:optional(number()),transactionCount:optional(number())});const GetEpochScheduleResult=type({slotsPerEpoch:number(),leaderScheduleSlotOffset:number(),warmup:boolean(),firstNormalEpoch:number(),firstNormalSlot:number()});const GetLeaderScheduleResult=record(string(),array(number()));const TransactionErrorResult=nullable(union([type({}),string()]));const SignatureStatusResult=type({err:TransactionErrorResult});const SignatureReceivedResult=literal("receivedSignature");const VersionResult=type({"solana-core":string(),"feature-set":optional(number())});const ParsedInstructionStruct=type({program:string(),programId:PublicKeyFromString,parsed:unknown()});const PartiallyDecodedInstructionStruct=type({programId:PublicKeyFromString,accounts:array(PublicKeyFromString),data:string()});const SimulatedTransactionResponseStruct=jsonRpcResultAndContext(type({err:nullable(union([type({}),string()])),logs:nullable(array(string())),accounts:optional(nullable(array(nullable(type({executable:boolean(),owner:string(),lamports:number(),data:array(string()),rentEpoch:optional(number())}))))),unitsConsumed:optional(number()),returnData:optional(nullable(type({programId:string(),data:tuple([string(),literal("base64")])}))),innerInstructions:optional(nullable(array(type({index:number(),instructions:array(union([ParsedInstructionStruct,PartiallyDecodedInstructionStruct]))}))))}));const BlockProductionResponseStruct=jsonRpcResultAndContext(type({byIdentity:record(string(),array(number())),range:type({firstSlot:number(),lastSlot:number()})}));function createRpcClient(url,httpHeaders,customFetch,fetchMiddleware,disableRetryOnRateLimit,httpAgent){const fetch=customFetch?customFetch:fetchImpl;let agent;{if(httpAgent!=null){console.warn("You have supplied an `httpAgent` when creating a `Connection` in a browser environment."+"It has been ignored; `httpAgent` is only used in Node environments.")}}let fetchWithMiddleware;if(fetchMiddleware){fetchWithMiddleware=async(info,init)=>{const modifiedFetchArgs=await new Promise(((resolve,reject)=>{try{fetchMiddleware(info,init,((modifiedInfo,modifiedInit)=>resolve([modifiedInfo,modifiedInit])))}catch(error){reject(error)}}));return await fetch(...modifiedFetchArgs)}}const clientBrowser=new RpcClient((async(request,callback)=>{const options={method:"POST",body:request,agent:agent,headers:Object.assign({"Content-Type":"application/json"},httpHeaders||{},COMMON_HTTP_HEADERS)};try{let too_many_requests_retries=5;let res;let waitTime=500;for(;;){if(fetchWithMiddleware){res=await fetchWithMiddleware(url,options)}else{res=await fetch(url,options)}if(res.status!==429){break}if(disableRetryOnRateLimit===true){break}too_many_requests_retries-=1;if(too_many_requests_retries===0){break}console.error(`Server responded with ${res.status} ${res.statusText}. Retrying after ${waitTime}ms delay...`);await sleep(waitTime);waitTime*=2}const text=await res.text();if(res.ok){callback(null,text)}else{callback(new Error(`${res.status} ${res.statusText}: ${text}`))}}catch(err){if(err instanceof Error)callback(err)}}),{});return clientBrowser}function createRpcRequest(client){return(method,args)=>new Promise(((resolve,reject)=>{client.request(method,args,((err,response)=>{if(err){reject(err);return}resolve(response)}))}))}function createRpcBatchRequest(client){return requests=>new Promise(((resolve,reject)=>{if(requests.length===0)resolve([]);const batch=requests.map((params=>client.request(params.methodName,params.args)));client.request(batch,((err,response)=>{if(err){reject(err);return}resolve(response)}))}))}const GetInflationGovernorRpcResult=jsonRpcResult(GetInflationGovernorResult);const GetInflationRateRpcResult=jsonRpcResult(GetInflationRateResult);const GetRecentPrioritizationFeesRpcResult=jsonRpcResult(GetRecentPrioritizationFeesResult);const GetEpochInfoRpcResult=jsonRpcResult(GetEpochInfoResult);const GetEpochScheduleRpcResult=jsonRpcResult(GetEpochScheduleResult);const GetLeaderScheduleRpcResult=jsonRpcResult(GetLeaderScheduleResult);const SlotRpcResult=jsonRpcResult(number());const GetSupplyRpcResult=jsonRpcResultAndContext(type({total:number(),circulating:number(),nonCirculating:number(),nonCirculatingAccounts:array(PublicKeyFromString)}));const TokenAmountResult=type({amount:string(),uiAmount:nullable(number()),decimals:number(),uiAmountString:optional(string())});const GetTokenLargestAccountsResult=jsonRpcResultAndContext(array(type({address:PublicKeyFromString,amount:string(),uiAmount:nullable(number()),decimals:number(),uiAmountString:optional(string())})));const GetTokenAccountsByOwner=jsonRpcResultAndContext(array(type({pubkey:PublicKeyFromString,account:type({executable:boolean(),owner:PublicKeyFromString,lamports:number(),data:BufferFromRawAccountData,rentEpoch:number()})})));const ParsedAccountDataResult=type({program:string(),parsed:unknown(),space:number()});const GetParsedTokenAccountsByOwner=jsonRpcResultAndContext(array(type({pubkey:PublicKeyFromString,account:type({executable:boolean(),owner:PublicKeyFromString,lamports:number(),data:ParsedAccountDataResult,rentEpoch:number()})})));const GetLargestAccountsRpcResult=jsonRpcResultAndContext(array(type({lamports:number(),address:PublicKeyFromString})));const AccountInfoResult=type({executable:boolean(),owner:PublicKeyFromString,lamports:number(),data:BufferFromRawAccountData,rentEpoch:number()});const KeyedAccountInfoResult=type({pubkey:PublicKeyFromString,account:AccountInfoResult});const ParsedOrRawAccountData=coerce(union([instance(bufferExports.Buffer),ParsedAccountDataResult]),union([RawAccountDataResult,ParsedAccountDataResult]),(value=>{if(Array.isArray(value)){return create(value,BufferFromRawAccountData)}else{return value}}));const ParsedAccountInfoResult=type({executable:boolean(),owner:PublicKeyFromString,lamports:number(),data:ParsedOrRawAccountData,rentEpoch:number()});const KeyedParsedAccountInfoResult=type({pubkey:PublicKeyFromString,account:ParsedAccountInfoResult});const StakeActivationResult=type({state:union([literal("active"),literal("inactive"),literal("activating"),literal("deactivating")]),active:number(),inactive:number()});const GetConfirmedSignaturesForAddress2RpcResult=jsonRpcResult(array(type({signature:string(),slot:number(),err:TransactionErrorResult,memo:nullable(string()),blockTime:optional(nullable(number()))})));const GetSignaturesForAddressRpcResult=jsonRpcResult(array(type({signature:string(),slot:number(),err:TransactionErrorResult,memo:nullable(string()),blockTime:optional(nullable(number()))})));const AccountNotificationResult=type({subscription:number(),result:notificationResultAndContext(AccountInfoResult)});const ProgramAccountInfoResult=type({pubkey:PublicKeyFromString,account:AccountInfoResult});const ProgramAccountNotificationResult=type({subscription:number(),result:notificationResultAndContext(ProgramAccountInfoResult)});const SlotInfoResult=type({parent:number(),slot:number(),root:number()});const SlotNotificationResult=type({subscription:number(),result:SlotInfoResult});const SlotUpdateResult=union([type({type:union([literal("firstShredReceived"),literal("completed"),literal("optimisticConfirmation"),literal("root")]),slot:number(),timestamp:number()}),type({type:literal("createdBank"),parent:number(),slot:number(),timestamp:number()}),type({type:literal("frozen"),slot:number(),timestamp:number(),stats:type({numTransactionEntries:number(),numSuccessfulTransactions:number(),numFailedTransactions:number(),maxTransactionsPerEntry:number()})}),type({type:literal("dead"),slot:number(),timestamp:number(),err:string()})]);const SlotUpdateNotificationResult=type({subscription:number(),result:SlotUpdateResult});const SignatureNotificationResult=type({subscription:number(),result:notificationResultAndContext(union([SignatureStatusResult,SignatureReceivedResult]))});const RootNotificationResult=type({subscription:number(),result:number()});const ContactInfoResult=type({pubkey:string(),gossip:nullable(string()),tpu:nullable(string()),rpc:nullable(string()),version:nullable(string())});const VoteAccountInfoResult=type({votePubkey:string(),nodePubkey:string(),activatedStake:number(),epochVoteAccount:boolean(),epochCredits:array(tuple([number(),number(),number()])),commission:number(),lastVote:number(),rootSlot:nullable(number())});const GetVoteAccounts=jsonRpcResult(type({current:array(VoteAccountInfoResult),delinquent:array(VoteAccountInfoResult)}));const ConfirmationStatus=union([literal("processed"),literal("confirmed"),literal("finalized")]);const SignatureStatusResponse=type({slot:number(),confirmations:nullable(number()),err:TransactionErrorResult,confirmationStatus:optional(ConfirmationStatus)});const GetSignatureStatusesRpcResult=jsonRpcResultAndContext(array(nullable(SignatureStatusResponse)));const GetMinimumBalanceForRentExemptionRpcResult=jsonRpcResult(number());const AddressTableLookupStruct=type({accountKey:PublicKeyFromString,writableIndexes:array(number()),readonlyIndexes:array(number())});const ConfirmedTransactionResult=type({signatures:array(string()),message:type({accountKeys:array(string()),header:type({numRequiredSignatures:number(),numReadonlySignedAccounts:number(),numReadonlyUnsignedAccounts:number()}),instructions:array(type({accounts:array(number()),data:string(),programIdIndex:number()})),recentBlockhash:string(),addressTableLookups:optional(array(AddressTableLookupStruct))})});const AnnotatedAccountKey=type({pubkey:PublicKeyFromString,signer:boolean(),writable:boolean(),source:optional(union([literal("transaction"),literal("lookupTable")]))});const ConfirmedTransactionAccountsModeResult=type({accountKeys:array(AnnotatedAccountKey),signatures:array(string())});const ParsedInstructionResult=type({parsed:unknown(),program:string(),programId:PublicKeyFromString});const RawInstructionResult=type({accounts:array(PublicKeyFromString),data:string(),programId:PublicKeyFromString});const InstructionResult=union([RawInstructionResult,ParsedInstructionResult]);const UnknownInstructionResult=union([type({parsed:unknown(),program:string(),programId:string()}),type({accounts:array(string()),data:string(),programId:string()})]);const ParsedOrRawInstruction=coerce(InstructionResult,UnknownInstructionResult,(value=>{if("accounts"in value){return create(value,RawInstructionResult)}else{return create(value,ParsedInstructionResult)}}));const ParsedConfirmedTransactionResult=type({signatures:array(string()),message:type({accountKeys:array(AnnotatedAccountKey),instructions:array(ParsedOrRawInstruction),recentBlockhash:string(),addressTableLookups:optional(nullable(array(AddressTableLookupStruct)))})});const TokenBalanceResult=type({accountIndex:number(),mint:string(),owner:optional(string()),programId:optional(string()),uiTokenAmount:TokenAmountResult});const LoadedAddressesResult=type({writable:array(PublicKeyFromString),readonly:array(PublicKeyFromString)});const ConfirmedTransactionMetaResult=type({err:TransactionErrorResult,fee:number(),innerInstructions:optional(nullable(array(type({index:number(),instructions:array(type({accounts:array(number()),data:string(),programIdIndex:number()}))})))),preBalances:array(number()),postBalances:array(number()),logMessages:optional(nullable(array(string()))),preTokenBalances:optional(nullable(array(TokenBalanceResult))),postTokenBalances:optional(nullable(array(TokenBalanceResult))),loadedAddresses:optional(LoadedAddressesResult),computeUnitsConsumed:optional(number()),costUnits:optional(number())});const ParsedConfirmedTransactionMetaResult=type({err:TransactionErrorResult,fee:number(),innerInstructions:optional(nullable(array(type({index:number(),instructions:array(ParsedOrRawInstruction)})))),preBalances:array(number()),postBalances:array(number()),logMessages:optional(nullable(array(string()))),preTokenBalances:optional(nullable(array(TokenBalanceResult))),postTokenBalances:optional(nullable(array(TokenBalanceResult))),loadedAddresses:optional(LoadedAddressesResult),computeUnitsConsumed:optional(number()),costUnits:optional(number())});const TransactionVersionStruct=union([literal(0),literal("legacy")]);const RewardsResult=type({pubkey:string(),lamports:number(),postBalance:nullable(number()),rewardType:nullable(string()),commission:optional(nullable(number()))});const GetBlockRpcResult=jsonRpcResult(nullable(type({blockhash:string(),previousBlockhash:string(),parentSlot:number(),transactions:array(type({transaction:ConfirmedTransactionResult,meta:nullable(ConfirmedTransactionMetaResult),version:optional(TransactionVersionStruct)})),rewards:optional(array(RewardsResult)),blockTime:nullable(number()),blockHeight:nullable(number())})));const GetNoneModeBlockRpcResult=jsonRpcResult(nullable(type({blockhash:string(),previousBlockhash:string(),parentSlot:number(),rewards:optional(array(RewardsResult)),blockTime:nullable(number()),blockHeight:nullable(number())})));const GetAccountsModeBlockRpcResult=jsonRpcResult(nullable(type({blockhash:string(),previousBlockhash:string(),parentSlot:number(),transactions:array(type({transaction:ConfirmedTransactionAccountsModeResult,meta:nullable(ConfirmedTransactionMetaResult),version:optional(TransactionVersionStruct)})),rewards:optional(array(RewardsResult)),blockTime:nullable(number()),blockHeight:nullable(number())})));const GetParsedBlockRpcResult=jsonRpcResult(nullable(type({blockhash:string(),previousBlockhash:string(),parentSlot:number(),transactions:array(type({transaction:ParsedConfirmedTransactionResult,meta:nullable(ParsedConfirmedTransactionMetaResult),version:optional(TransactionVersionStruct)})),rewards:optional(array(RewardsResult)),blockTime:nullable(number()),blockHeight:nullable(number())})));const GetParsedAccountsModeBlockRpcResult=jsonRpcResult(nullable(type({blockhash:string(),previousBlockhash:string(),parentSlot:number(),transactions:array(type({transaction:ConfirmedTransactionAccountsModeResult,meta:nullable(ParsedConfirmedTransactionMetaResult),version:optional(TransactionVersionStruct)})),rewards:optional(array(RewardsResult)),blockTime:nullable(number()),blockHeight:nullable(number())})));const GetParsedNoneModeBlockRpcResult=jsonRpcResult(nullable(type({blockhash:string(),previousBlockhash:string(),parentSlot:number(),rewards:optional(array(RewardsResult)),blockTime:nullable(number()),blockHeight:nullable(number())})));const GetConfirmedBlockRpcResult=jsonRpcResult(nullable(type({blockhash:string(),previousBlockhash:string(),parentSlot:number(),transactions:array(type({transaction:ConfirmedTransactionResult,meta:nullable(ConfirmedTransactionMetaResult)})),rewards:optional(array(RewardsResult)),blockTime:nullable(number())})));const GetBlockSignaturesRpcResult=jsonRpcResult(nullable(type({blockhash:string(),previousBlockhash:string(),parentSlot:number(),signatures:array(string()),blockTime:nullable(number())})));const GetTransactionRpcResult=jsonRpcResult(nullable(type({slot:number(),meta:nullable(ConfirmedTransactionMetaResult),blockTime:optional(nullable(number())),transaction:ConfirmedTransactionResult,version:optional(TransactionVersionStruct)})));const GetParsedTransactionRpcResult=jsonRpcResult(nullable(type({slot:number(),transaction:ParsedConfirmedTransactionResult,meta:nullable(ParsedConfirmedTransactionMetaResult),blockTime:optional(nullable(number())),version:optional(TransactionVersionStruct)})));const GetLatestBlockhashRpcResult=jsonRpcResultAndContext(type({blockhash:string(),lastValidBlockHeight:number()}));const IsBlockhashValidRpcResult=jsonRpcResultAndContext(boolean());const PerfSampleResult=type({slot:number(),numTransactions:number(),numSlots:number(),samplePeriodSecs:number()});const GetRecentPerformanceSamplesRpcResult=jsonRpcResult(array(PerfSampleResult));const GetFeeCalculatorRpcResult=jsonRpcResultAndContext(nullable(type({feeCalculator:type({lamportsPerSignature:number()})})));const RequestAirdropRpcResult=jsonRpcResult(string());const SendTransactionRpcResult=jsonRpcResult(string());const LogsResult=type({err:TransactionErrorResult,logs:array(string()),signature:string()});const LogsNotificationResult=type({result:notificationResultAndContext(LogsResult),subscription:number()});const COMMON_HTTP_HEADERS={"solana-client":`js/${"1.0.0-maintenance"}`};class Connection{constructor(endpoint,_commitmentOrConfig){this._commitment=void 0;this._confirmTransactionInitialTimeout=void 0;this._rpcEndpoint=void 0;this._rpcWsEndpoint=void 0;this._rpcClient=void 0;this._rpcRequest=void 0;this._rpcBatchRequest=void 0;this._rpcWebSocket=void 0;this._rpcWebSocketConnected=false;this._rpcWebSocketHeartbeat=null;this._rpcWebSocketIdleTimeout=null;this._rpcWebSocketGeneration=0;this._disableBlockhashCaching=false;this._pollingBlockhash=false;this._blockhashInfo={latestBlockhash:null,lastFetch:0,transactionSignatures:[],simulatedSignatures:[]};this._nextClientSubscriptionId=0;this._subscriptionDisposeFunctionsByClientSubscriptionId={};this._subscriptionHashByClientSubscriptionId={};this._subscriptionStateChangeCallbacksByHash={};this._subscriptionCallbacksByServerSubscriptionId={};this._subscriptionsByHash={};this._subscriptionsAutoDisposedByRpc=new Set;this.getBlockHeight=(()=>{const requestPromises={};return async commitmentOrConfig=>{const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([],commitment,undefined,config);const requestHash=fastStableStringify(args);requestPromises[requestHash]=requestPromises[requestHash]??(async()=>{try{const unsafeRes=await this._rpcRequest("getBlockHeight",args);const res=create(unsafeRes,jsonRpcResult(number()));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get block height information")}return res.result}finally{delete requestPromises[requestHash]}})();return await requestPromises[requestHash]}})();let wsEndpoint;let httpHeaders;let fetch;let fetchMiddleware;let disableRetryOnRateLimit;let httpAgent;if(_commitmentOrConfig&&typeof _commitmentOrConfig==="string"){this._commitment=_commitmentOrConfig}else if(_commitmentOrConfig){this._commitment=_commitmentOrConfig.commitment;this._confirmTransactionInitialTimeout=_commitmentOrConfig.confirmTransactionInitialTimeout;wsEndpoint=_commitmentOrConfig.wsEndpoint;httpHeaders=_commitmentOrConfig.httpHeaders;fetch=_commitmentOrConfig.fetch;fetchMiddleware=_commitmentOrConfig.fetchMiddleware;disableRetryOnRateLimit=_commitmentOrConfig.disableRetryOnRateLimit;httpAgent=_commitmentOrConfig.httpAgent}this._rpcEndpoint=assertEndpointUrl(endpoint);this._rpcWsEndpoint=wsEndpoint||makeWebsocketUrl(endpoint);this._rpcClient=createRpcClient(endpoint,httpHeaders,fetch,fetchMiddleware,disableRetryOnRateLimit,httpAgent);this._rpcRequest=createRpcRequest(this._rpcClient);this._rpcBatchRequest=createRpcBatchRequest(this._rpcClient);this._rpcWebSocket=new RpcWebSocketClient(this._rpcWsEndpoint,{autoconnect:false,max_reconnects:Infinity});this._rpcWebSocket.on("open",this._wsOnOpen.bind(this));this._rpcWebSocket.on("error",this._wsOnError.bind(this));this._rpcWebSocket.on("close",this._wsOnClose.bind(this));this._rpcWebSocket.on("accountNotification",this._wsOnAccountNotification.bind(this));this._rpcWebSocket.on("programNotification",this._wsOnProgramAccountNotification.bind(this));this._rpcWebSocket.on("slotNotification",this._wsOnSlotNotification.bind(this));this._rpcWebSocket.on("slotsUpdatesNotification",this._wsOnSlotUpdatesNotification.bind(this));this._rpcWebSocket.on("signatureNotification",this._wsOnSignatureNotification.bind(this));this._rpcWebSocket.on("rootNotification",this._wsOnRootNotification.bind(this));this._rpcWebSocket.on("logsNotification",this._wsOnLogsNotification.bind(this))}get commitment(){return this._commitment}get rpcEndpoint(){return this._rpcEndpoint}async getBalanceAndContext(publicKey,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([publicKey.toBase58()],commitment,undefined,config);const unsafeRes=await this._rpcRequest("getBalance",args);const res=create(unsafeRes,jsonRpcResultAndContext(number()));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get balance for ${publicKey.toBase58()}`)}return res.result}async getBalance(publicKey,commitmentOrConfig){return await this.getBalanceAndContext(publicKey,commitmentOrConfig).then((x=>x.value)).catch((e=>{throw new Error("failed to get balance of account "+publicKey.toBase58()+": "+e)}))}async getBlockTime(slot){const unsafeRes=await this._rpcRequest("getBlockTime",[slot]);const res=create(unsafeRes,jsonRpcResult(nullable(number())));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get block time for slot ${slot}`)}return res.result}async getMinimumLedgerSlot(){const unsafeRes=await this._rpcRequest("minimumLedgerSlot",[]);const res=create(unsafeRes,jsonRpcResult(number()));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get minimum ledger slot")}return res.result}async getFirstAvailableBlock(){const unsafeRes=await this._rpcRequest("getFirstAvailableBlock",[]);const res=create(unsafeRes,SlotRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get first available block")}return res.result}async getSupply(config){let configArg={};if(typeof config==="string"){configArg={commitment:config}}else if(config){configArg={...config,commitment:config&&config.commitment||this.commitment}}else{configArg={commitment:this.commitment}}const unsafeRes=await this._rpcRequest("getSupply",[configArg]);const res=create(unsafeRes,GetSupplyRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get supply")}return res.result}async getTokenSupply(tokenMintAddress,commitment){const args=this._buildArgs([tokenMintAddress.toBase58()],commitment);const unsafeRes=await this._rpcRequest("getTokenSupply",args);const res=create(unsafeRes,jsonRpcResultAndContext(TokenAmountResult));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get token supply")}return res.result}async getTokenAccountBalance(tokenAddress,commitment){const args=this._buildArgs([tokenAddress.toBase58()],commitment);const unsafeRes=await this._rpcRequest("getTokenAccountBalance",args);const res=create(unsafeRes,jsonRpcResultAndContext(TokenAmountResult));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get token account balance")}return res.result}async getTokenAccountsByOwner(ownerAddress,filter,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);let _args=[ownerAddress.toBase58()];if("mint"in filter){_args.push({mint:filter.mint.toBase58()})}else{_args.push({programId:filter.programId.toBase58()})}const args=this._buildArgs(_args,commitment,"base64",config);const unsafeRes=await this._rpcRequest("getTokenAccountsByOwner",args);const res=create(unsafeRes,GetTokenAccountsByOwner);if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get token accounts owned by account ${ownerAddress.toBase58()}`)}return res.result}async getParsedTokenAccountsByOwner(ownerAddress,filter,commitment){let _args=[ownerAddress.toBase58()];if("mint"in filter){_args.push({mint:filter.mint.toBase58()})}else{_args.push({programId:filter.programId.toBase58()})}const args=this._buildArgs(_args,commitment,"jsonParsed");const unsafeRes=await this._rpcRequest("getTokenAccountsByOwner",args);const res=create(unsafeRes,GetParsedTokenAccountsByOwner);if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get token accounts owned by account ${ownerAddress.toBase58()}`)}return res.result}async getLargestAccounts(config){const arg={...config,commitment:config&&config.commitment||this.commitment};const args=arg.filter||arg.commitment?[arg]:[];const unsafeRes=await this._rpcRequest("getLargestAccounts",args);const res=create(unsafeRes,GetLargestAccountsRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get largest accounts")}return res.result}async getTokenLargestAccounts(mintAddress,commitment){const args=this._buildArgs([mintAddress.toBase58()],commitment);const unsafeRes=await this._rpcRequest("getTokenLargestAccounts",args);const res=create(unsafeRes,GetTokenLargestAccountsResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get token largest accounts")}return res.result}async getAccountInfoAndContext(publicKey,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([publicKey.toBase58()],commitment,"base64",config);const unsafeRes=await this._rpcRequest("getAccountInfo",args);const res=create(unsafeRes,jsonRpcResultAndContext(nullable(AccountInfoResult)));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get info about account ${publicKey.toBase58()}`)}return res.result}async getParsedAccountInfo(publicKey,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([publicKey.toBase58()],commitment,"jsonParsed",config);const unsafeRes=await this._rpcRequest("getAccountInfo",args);const res=create(unsafeRes,jsonRpcResultAndContext(nullable(ParsedAccountInfoResult)));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get info about account ${publicKey.toBase58()}`)}return res.result}async getAccountInfo(publicKey,commitmentOrConfig){try{const res=await this.getAccountInfoAndContext(publicKey,commitmentOrConfig);return res.value}catch(e){throw new Error("failed to get info about account "+publicKey.toBase58()+": "+e)}}async getMultipleParsedAccounts(publicKeys,rawConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(rawConfig);const keys=publicKeys.map((key=>key.toBase58()));const args=this._buildArgs([keys],commitment,"jsonParsed",config);const unsafeRes=await this._rpcRequest("getMultipleAccounts",args);const res=create(unsafeRes,jsonRpcResultAndContext(array(nullable(ParsedAccountInfoResult))));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get info for accounts ${keys}`)}return res.result}async getMultipleAccountsInfoAndContext(publicKeys,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const keys=publicKeys.map((key=>key.toBase58()));const args=this._buildArgs([keys],commitment,"base64",config);const unsafeRes=await this._rpcRequest("getMultipleAccounts",args);const res=create(unsafeRes,jsonRpcResultAndContext(array(nullable(AccountInfoResult))));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get info for accounts ${keys}`)}return res.result}async getMultipleAccountsInfo(publicKeys,commitmentOrConfig){const res=await this.getMultipleAccountsInfoAndContext(publicKeys,commitmentOrConfig);return res.value}async getStakeActivation(publicKey,commitmentOrConfig,epoch){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([publicKey.toBase58()],commitment,undefined,{...config,epoch:epoch!=null?epoch:config?.epoch});const unsafeRes=await this._rpcRequest("getStakeActivation",args);const res=create(unsafeRes,jsonRpcResult(StakeActivationResult));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get Stake Activation ${publicKey.toBase58()}`)}return res.result}async getProgramAccounts(programId,configOrCommitment){const{commitment:commitment,config:config}=extractCommitmentFromConfig(configOrCommitment);const{encoding:encoding,...configWithoutEncoding}=config||{};const args=this._buildArgs([programId.toBase58()],commitment,encoding||"base64",{...configWithoutEncoding,...configWithoutEncoding.filters?{filters:applyDefaultMemcmpEncodingToFilters(configWithoutEncoding.filters)}:null});const unsafeRes=await this._rpcRequest("getProgramAccounts",args);const baseSchema=array(KeyedAccountInfoResult);const res=configWithoutEncoding.withContext===true?create(unsafeRes,jsonRpcResultAndContext(baseSchema)):create(unsafeRes,jsonRpcResult(baseSchema));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get accounts owned by program ${programId.toBase58()}`)}return res.result}async getParsedProgramAccounts(programId,configOrCommitment){const{commitment:commitment,config:config}=extractCommitmentFromConfig(configOrCommitment);const args=this._buildArgs([programId.toBase58()],commitment,"jsonParsed",config);const unsafeRes=await this._rpcRequest("getProgramAccounts",args);const res=create(unsafeRes,jsonRpcResult(array(KeyedParsedAccountInfoResult)));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get accounts owned by program ${programId.toBase58()}`)}return res.result}async confirmTransaction(strategy,commitment){let rawSignature;if(typeof strategy=="string"){rawSignature=strategy}else{const config=strategy;if(config.abortSignal?.aborted){return Promise.reject(config.abortSignal.reason)}rawSignature=config.signature}let decodedSignature;try{decodedSignature=bs58.decode(rawSignature)}catch(err){throw new Error("signature must be base58 encoded: "+rawSignature)}assert$1(decodedSignature.length===64,"signature has invalid length");if(typeof strategy==="string"){return await this.confirmTransactionUsingLegacyTimeoutStrategy({commitment:commitment||this.commitment,signature:rawSignature})}else if("lastValidBlockHeight"in strategy){return await this.confirmTransactionUsingBlockHeightExceedanceStrategy({commitment:commitment||this.commitment,strategy:strategy})}else{return await this.confirmTransactionUsingDurableNonceStrategy({commitment:commitment||this.commitment,strategy:strategy})}}getCancellationPromise(signal){return new Promise(((_,reject)=>{if(signal==null){return}if(signal.aborted){reject(signal.reason)}else{signal.addEventListener("abort",(()=>{reject(signal.reason)}))}}))}getTransactionConfirmationPromise({commitment:commitment,signature:signature}){let signatureSubscriptionId;let disposeSignatureSubscriptionStateChangeObserver;let done=false;const confirmationPromise=new Promise(((resolve,reject)=>{try{signatureSubscriptionId=this.onSignature(signature,((result,context)=>{signatureSubscriptionId=undefined;const response={context:context,value:result};resolve({__type:TransactionStatus.PROCESSED,response:response})}),commitment);const subscriptionSetupPromise=new Promise((resolveSubscriptionSetup=>{if(signatureSubscriptionId==null){resolveSubscriptionSetup()}else{disposeSignatureSubscriptionStateChangeObserver=this._onSubscriptionStateChange(signatureSubscriptionId,(nextState=>{if(nextState==="subscribed"){resolveSubscriptionSetup()}}))}}));(async()=>{await subscriptionSetupPromise;if(done)return;const response=await this.getSignatureStatus(signature);if(done)return;if(response==null){return}const{context:context,value:value}=response;if(value==null){return}if(value?.err){reject(value.err)}else{switch(commitment){case"confirmed":case"single":case"singleGossip":{if(value.confirmationStatus==="processed"){return}break}case"finalized":case"max":case"root":{if(value.confirmationStatus==="processed"||value.confirmationStatus==="confirmed"){return}break}case"processed":case"recent":}done=true;resolve({__type:TransactionStatus.PROCESSED,response:{context:context,value:value}})}})()}catch(err){reject(err)}}));const abortConfirmation=()=>{if(disposeSignatureSubscriptionStateChangeObserver){disposeSignatureSubscriptionStateChangeObserver();disposeSignatureSubscriptionStateChangeObserver=undefined}if(signatureSubscriptionId!=null){this.removeSignatureListener(signatureSubscriptionId);signatureSubscriptionId=undefined}};return{abortConfirmation:abortConfirmation,confirmationPromise:confirmationPromise}}async confirmTransactionUsingBlockHeightExceedanceStrategy({commitment:commitment,strategy:{abortSignal:abortSignal,lastValidBlockHeight:lastValidBlockHeight,signature:signature}}){let done=false;const expiryPromise=new Promise((resolve=>{const checkBlockHeight=async()=>{try{const blockHeight=await this.getBlockHeight(commitment);return blockHeight}catch(_e){return-1}};(async()=>{let currentBlockHeight=await checkBlockHeight();if(done)return;while(currentBlockHeight<=lastValidBlockHeight){await sleep(1e3);if(done)return;currentBlockHeight=await checkBlockHeight();if(done)return}resolve({__type:TransactionStatus.BLOCKHEIGHT_EXCEEDED})})()}));const{abortConfirmation:abortConfirmation,confirmationPromise:confirmationPromise}=this.getTransactionConfirmationPromise({commitment:commitment,signature:signature});const cancellationPromise=this.getCancellationPromise(abortSignal);let result;try{const outcome=await Promise.race([cancellationPromise,confirmationPromise,expiryPromise]);if(outcome.__type===TransactionStatus.PROCESSED){result=outcome.response}else{throw new TransactionExpiredBlockheightExceededError(signature)}}finally{done=true;abortConfirmation()}return result}async confirmTransactionUsingDurableNonceStrategy({commitment:commitment,strategy:{abortSignal:abortSignal,minContextSlot:minContextSlot,nonceAccountPubkey:nonceAccountPubkey,nonceValue:nonceValue,signature:signature}}){let done=false;const expiryPromise=new Promise((resolve=>{let currentNonceValue=nonceValue;let lastCheckedSlot=null;const getCurrentNonceValue=async()=>{try{const{context:context,value:nonceAccount}=await this.getNonceAndContext(nonceAccountPubkey,{commitment:commitment,minContextSlot:minContextSlot});lastCheckedSlot=context.slot;return nonceAccount?.nonce}catch(e){return currentNonceValue}};(async()=>{currentNonceValue=await getCurrentNonceValue();if(done)return;while(true){if(nonceValue!==currentNonceValue){resolve({__type:TransactionStatus.NONCE_INVALID,slotInWhichNonceDidAdvance:lastCheckedSlot});return}await sleep(2e3);if(done)return;currentNonceValue=await getCurrentNonceValue();if(done)return}})()}));const{abortConfirmation:abortConfirmation,confirmationPromise:confirmationPromise}=this.getTransactionConfirmationPromise({commitment:commitment,signature:signature});const cancellationPromise=this.getCancellationPromise(abortSignal);let result;try{const outcome=await Promise.race([cancellationPromise,confirmationPromise,expiryPromise]);if(outcome.__type===TransactionStatus.PROCESSED){result=outcome.response}else{let signatureStatus;while(true){const status=await this.getSignatureStatus(signature);if(status==null){break}if(status.context.slot<(outcome.slotInWhichNonceDidAdvance??minContextSlot)){await sleep(400);continue}signatureStatus=status;break}if(signatureStatus?.value){const commitmentForStatus=commitment||"finalized";const{confirmationStatus:confirmationStatus}=signatureStatus.value;switch(commitmentForStatus){case"processed":case"recent":if(confirmationStatus!=="processed"&&confirmationStatus!=="confirmed"&&confirmationStatus!=="finalized"){throw new TransactionExpiredNonceInvalidError(signature)}break;case"confirmed":case"single":case"singleGossip":if(confirmationStatus!=="confirmed"&&confirmationStatus!=="finalized"){throw new TransactionExpiredNonceInvalidError(signature)}break;case"finalized":case"max":case"root":if(confirmationStatus!=="finalized"){throw new TransactionExpiredNonceInvalidError(signature)}break;default:(_=>{})(commitmentForStatus)}result={context:signatureStatus.context,value:{err:signatureStatus.value.err}}}else{throw new TransactionExpiredNonceInvalidError(signature)}}}finally{done=true;abortConfirmation()}return result}async confirmTransactionUsingLegacyTimeoutStrategy({commitment:commitment,signature:signature}){let timeoutId;const expiryPromise=new Promise((resolve=>{let timeoutMs=this._confirmTransactionInitialTimeout||60*1e3;switch(commitment){case"processed":case"recent":case"single":case"confirmed":case"singleGossip":{timeoutMs=this._confirmTransactionInitialTimeout||30*1e3;break}}timeoutId=setTimeout((()=>resolve({__type:TransactionStatus.TIMED_OUT,timeoutMs:timeoutMs})),timeoutMs)}));const{abortConfirmation:abortConfirmation,confirmationPromise:confirmationPromise}=this.getTransactionConfirmationPromise({commitment:commitment,signature:signature});let result;try{const outcome=await Promise.race([confirmationPromise,expiryPromise]);if(outcome.__type===TransactionStatus.PROCESSED){result=outcome.response}else{throw new TransactionExpiredTimeoutError(signature,outcome.timeoutMs/1e3)}}finally{clearTimeout(timeoutId);abortConfirmation()}return result}async getClusterNodes(){const unsafeRes=await this._rpcRequest("getClusterNodes",[]);const res=create(unsafeRes,jsonRpcResult(array(ContactInfoResult)));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get cluster nodes")}return res.result}async getVoteAccounts(commitment){const args=this._buildArgs([],commitment);const unsafeRes=await this._rpcRequest("getVoteAccounts",args);const res=create(unsafeRes,GetVoteAccounts);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get vote accounts")}return res.result}async getSlot(commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([],commitment,undefined,config);const unsafeRes=await this._rpcRequest("getSlot",args);const res=create(unsafeRes,jsonRpcResult(number()));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get slot")}return res.result}async getSlotLeader(commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([],commitment,undefined,config);const unsafeRes=await this._rpcRequest("getSlotLeader",args);const res=create(unsafeRes,jsonRpcResult(string()));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get slot leader")}return res.result}async getSlotLeaders(startSlot,limit){const args=[startSlot,limit];const unsafeRes=await this._rpcRequest("getSlotLeaders",args);const res=create(unsafeRes,jsonRpcResult(array(PublicKeyFromString)));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get slot leaders")}return res.result}async getSignatureStatus(signature,config){const{context:context,value:values}=await this.getSignatureStatuses([signature],config);assert$1(values.length===1);const value=values[0];return{context:context,value:value}}async getSignatureStatuses(signatures,config){const params=[signatures];if(config){params.push(config)}const unsafeRes=await this._rpcRequest("getSignatureStatuses",params);const res=create(unsafeRes,GetSignatureStatusesRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get signature status")}return res.result}async getTransactionCount(commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([],commitment,undefined,config);const unsafeRes=await this._rpcRequest("getTransactionCount",args);const res=create(unsafeRes,jsonRpcResult(number()));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get transaction count")}return res.result}async getTotalSupply(commitment){const result=await this.getSupply({commitment:commitment,excludeNonCirculatingAccountsList:true});return result.value.total}async getInflationGovernor(commitment){const args=this._buildArgs([],commitment);const unsafeRes=await this._rpcRequest("getInflationGovernor",args);const res=create(unsafeRes,GetInflationGovernorRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get inflation")}return res.result}async getInflationReward(addresses,epoch,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([addresses.map((pubkey=>pubkey.toBase58()))],commitment,undefined,{...config,epoch:epoch!=null?epoch:config?.epoch});const unsafeRes=await this._rpcRequest("getInflationReward",args);const res=create(unsafeRes,GetInflationRewardResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get inflation reward")}return res.result}async getInflationRate(){const unsafeRes=await this._rpcRequest("getInflationRate",[]);const res=create(unsafeRes,GetInflationRateRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get inflation rate")}return res.result}async getEpochInfo(commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([],commitment,undefined,config);const unsafeRes=await this._rpcRequest("getEpochInfo",args);const res=create(unsafeRes,GetEpochInfoRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get epoch info")}return res.result}async getEpochSchedule(){const unsafeRes=await this._rpcRequest("getEpochSchedule",[]);const res=create(unsafeRes,GetEpochScheduleRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get epoch schedule")}const epochSchedule=res.result;return new EpochSchedule(epochSchedule.slotsPerEpoch,epochSchedule.leaderScheduleSlotOffset,epochSchedule.warmup,epochSchedule.firstNormalEpoch,epochSchedule.firstNormalSlot)}async getLeaderSchedule(){const unsafeRes=await this._rpcRequest("getLeaderSchedule",[]);const res=create(unsafeRes,GetLeaderScheduleRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get leader schedule")}return res.result}async getMinimumBalanceForRentExemption(dataLength,commitment){const args=this._buildArgs([dataLength],commitment);const unsafeRes=await this._rpcRequest("getMinimumBalanceForRentExemption",args);const res=create(unsafeRes,GetMinimumBalanceForRentExemptionRpcResult);if("error"in res){console.warn("Unable to fetch minimum balance for rent exemption");return 0}return res.result}async getRecentBlockhashAndContext(commitment){const{context:context,value:{blockhash:blockhash}}=await this.getLatestBlockhashAndContext(commitment);const feeCalculator={get lamportsPerSignature(){throw new Error("The capability to fetch `lamportsPerSignature` using the `getRecentBlockhash` API is "+"no longer offered by the network. Use the `getFeeForMessage` API to obtain the fee "+"for a given message.")},toJSON(){return{}}};return{context:context,value:{blockhash:blockhash,feeCalculator:feeCalculator}}}async getRecentPerformanceSamples(limit){const unsafeRes=await this._rpcRequest("getRecentPerformanceSamples",limit?[limit]:[]);const res=create(unsafeRes,GetRecentPerformanceSamplesRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get recent performance samples")}return res.result}async getFeeCalculatorForBlockhash(blockhash,commitment){const args=this._buildArgs([blockhash],commitment);const unsafeRes=await this._rpcRequest("getFeeCalculatorForBlockhash",args);const res=create(unsafeRes,GetFeeCalculatorRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get fee calculator")}const{context:context,value:value}=res.result;return{context:context,value:value!==null?value.feeCalculator:null}}async getFeeForMessage(message,commitment){const wireMessage=toBuffer(message.serialize()).toString("base64");const args=this._buildArgs([wireMessage],commitment);const unsafeRes=await this._rpcRequest("getFeeForMessage",args);const res=create(unsafeRes,jsonRpcResultAndContext(nullable(number())));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get fee for message")}if(res.result===null){throw new Error("invalid blockhash")}return res.result}async getRecentPrioritizationFees(config){const accounts=config?.lockedWritableAccounts?.map((key=>key.toBase58()));const args=accounts?.length?[accounts]:[];const unsafeRes=await this._rpcRequest("getRecentPrioritizationFees",args);const res=create(unsafeRes,GetRecentPrioritizationFeesRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get recent prioritization fees")}return res.result}async getRecentBlockhash(commitment){try{const res=await this.getRecentBlockhashAndContext(commitment);return res.value}catch(e){throw new Error("failed to get recent blockhash: "+e)}}async getLatestBlockhash(commitmentOrConfig){try{const res=await this.getLatestBlockhashAndContext(commitmentOrConfig);return res.value}catch(e){throw new Error("failed to get recent blockhash: "+e)}}async getLatestBlockhashAndContext(commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([],commitment,undefined,config);const unsafeRes=await this._rpcRequest("getLatestBlockhash",args);const res=create(unsafeRes,GetLatestBlockhashRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get latest blockhash")}return res.result}async isBlockhashValid(blockhash,rawConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(rawConfig);const args=this._buildArgs([blockhash],commitment,undefined,config);const unsafeRes=await this._rpcRequest("isBlockhashValid",args);const res=create(unsafeRes,IsBlockhashValidRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to determine if the blockhash `"+blockhash+"`is valid")}return res.result}async getVersion(){const unsafeRes=await this._rpcRequest("getVersion",[]);const res=create(unsafeRes,jsonRpcResult(VersionResult));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get version")}return res.result}async getGenesisHash(){const unsafeRes=await this._rpcRequest("getGenesisHash",[]);const res=create(unsafeRes,jsonRpcResult(string()));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get genesis hash")}return res.result}async getBlock(slot,rawConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(rawConfig);const args=this._buildArgsAtLeastConfirmed([slot],commitment,undefined,config);const unsafeRes=await this._rpcRequest("getBlock",args);try{switch(config?.transactionDetails){case"accounts":{const res=create(unsafeRes,GetAccountsModeBlockRpcResult);if("error"in res){throw res.error}return res.result}case"none":{const res=create(unsafeRes,GetNoneModeBlockRpcResult);if("error"in res){throw res.error}return res.result}default:{const res=create(unsafeRes,GetBlockRpcResult);if("error"in res){throw res.error}const{result:result}=res;return result?{...result,transactions:result.transactions.map((({transaction:transaction,meta:meta,version:version})=>({meta:meta,transaction:{...transaction,message:versionedMessageFromResponse(version,transaction.message)},version:version})))}:null}}}catch(e){throw new SolanaJSONRPCError(e,"failed to get confirmed block")}}async getParsedBlock(slot,rawConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(rawConfig);const args=this._buildArgsAtLeastConfirmed([slot],commitment,"jsonParsed",config);const unsafeRes=await this._rpcRequest("getBlock",args);try{switch(config?.transactionDetails){case"accounts":{const res=create(unsafeRes,GetParsedAccountsModeBlockRpcResult);if("error"in res){throw res.error}return res.result}case"none":{const res=create(unsafeRes,GetParsedNoneModeBlockRpcResult);if("error"in res){throw res.error}return res.result}default:{const res=create(unsafeRes,GetParsedBlockRpcResult);if("error"in res){throw res.error}return res.result}}}catch(e){throw new SolanaJSONRPCError(e,"failed to get block")}}async getBlockProduction(configOrCommitment){let extra;let commitment;if(typeof configOrCommitment==="string"){commitment=configOrCommitment}else if(configOrCommitment){const{commitment:c,...rest}=configOrCommitment;commitment=c;extra=rest}const args=this._buildArgs([],commitment,"base64",extra);const unsafeRes=await this._rpcRequest("getBlockProduction",args);const res=create(unsafeRes,BlockProductionResponseStruct);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get block production information")}return res.result}async getTransaction(signature,rawConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(rawConfig);const args=this._buildArgsAtLeastConfirmed([signature],commitment,undefined,config);const unsafeRes=await this._rpcRequest("getTransaction",args);const res=create(unsafeRes,GetTransactionRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get transaction")}const result=res.result;if(!result)return result;return{...result,transaction:{...result.transaction,message:versionedMessageFromResponse(result.version,result.transaction.message)}}}async getParsedTransaction(signature,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgsAtLeastConfirmed([signature],commitment,"jsonParsed",config);const unsafeRes=await this._rpcRequest("getTransaction",args);const res=create(unsafeRes,GetParsedTransactionRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get transaction")}return res.result}async getParsedTransactions(signatures,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const batch=signatures.map((signature=>{const args=this._buildArgsAtLeastConfirmed([signature],commitment,"jsonParsed",config);return{methodName:"getTransaction",args:args}}));const unsafeRes=await this._rpcBatchRequest(batch);const res=unsafeRes.map((unsafeRes=>{const res=create(unsafeRes,GetParsedTransactionRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get transactions")}return res.result}));return res}async getTransactions(signatures,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const batch=signatures.map((signature=>{const args=this._buildArgsAtLeastConfirmed([signature],commitment,undefined,config);return{methodName:"getTransaction",args:args}}));const unsafeRes=await this._rpcBatchRequest(batch);const res=unsafeRes.map((unsafeRes=>{const res=create(unsafeRes,GetTransactionRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get transactions")}const result=res.result;if(!result)return result;return{...result,transaction:{...result.transaction,message:versionedMessageFromResponse(result.version,result.transaction.message)}}}));return res}async getConfirmedBlock(slot,commitment){const args=this._buildArgsAtLeastConfirmed([slot],commitment);const unsafeRes=await this._rpcRequest("getBlock",args);const res=create(unsafeRes,GetConfirmedBlockRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get confirmed block")}const result=res.result;if(!result){throw new Error("Confirmed block "+slot+" not found")}const block={...result,transactions:result.transactions.map((({transaction:transaction,meta:meta})=>{const message=new Message(transaction.message);return{meta:meta,transaction:{...transaction,message:message}}}))};return{...block,transactions:block.transactions.map((({transaction:transaction,meta:meta})=>({meta:meta,transaction:Transaction.populate(transaction.message,transaction.signatures)})))}}async getBlocks(startSlot,endSlot,commitment){const args=this._buildArgsAtLeastConfirmed(endSlot!==undefined?[startSlot,endSlot]:[startSlot],commitment);const unsafeRes=await this._rpcRequest("getBlocks",args);const res=create(unsafeRes,jsonRpcResult(array(number())));if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get blocks")}return res.result}async getBlockSignatures(slot,commitment){const args=this._buildArgsAtLeastConfirmed([slot],commitment,undefined,{transactionDetails:"signatures",rewards:false});const unsafeRes=await this._rpcRequest("getBlock",args);const res=create(unsafeRes,GetBlockSignaturesRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get block")}const result=res.result;if(!result){throw new Error("Block "+slot+" not found")}return result}async getConfirmedBlockSignatures(slot,commitment){const args=this._buildArgsAtLeastConfirmed([slot],commitment,undefined,{transactionDetails:"signatures",rewards:false});const unsafeRes=await this._rpcRequest("getBlock",args);const res=create(unsafeRes,GetBlockSignaturesRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get confirmed block")}const result=res.result;if(!result){throw new Error("Confirmed block "+slot+" not found")}return result}async getConfirmedTransaction(signature,commitment){const args=this._buildArgsAtLeastConfirmed([signature],commitment);const unsafeRes=await this._rpcRequest("getTransaction",args);const res=create(unsafeRes,GetTransactionRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get transaction")}const result=res.result;if(!result)return result;const message=new Message(result.transaction.message);const signatures=result.transaction.signatures;return{...result,transaction:Transaction.populate(message,signatures)}}async getParsedConfirmedTransaction(signature,commitment){const args=this._buildArgsAtLeastConfirmed([signature],commitment,"jsonParsed");const unsafeRes=await this._rpcRequest("getTransaction",args);const res=create(unsafeRes,GetParsedTransactionRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get confirmed transaction")}return res.result}async getParsedConfirmedTransactions(signatures,commitment){const batch=signatures.map((signature=>{const args=this._buildArgsAtLeastConfirmed([signature],commitment,"jsonParsed");return{methodName:"getTransaction",args:args}}));const unsafeRes=await this._rpcBatchRequest(batch);const res=unsafeRes.map((unsafeRes=>{const res=create(unsafeRes,GetParsedTransactionRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get confirmed transactions")}return res.result}));return res}async getConfirmedSignaturesForAddress(address,startSlot,endSlot){let options={};let firstAvailableBlock=await this.getFirstAvailableBlock();while(!("until"in options)){startSlot--;if(startSlot<=0||startSlot0){options.until=block.signatures[block.signatures.length-1].toString()}}catch(err){if(err instanceof Error&&err.message.includes("skipped")){continue}else{throw err}}}let highestConfirmedRoot=await this.getSlot("finalized");while(!("before"in options)){endSlot++;if(endSlot>highestConfirmedRoot){break}try{const block=await this.getConfirmedBlockSignatures(endSlot);if(block.signatures.length>0){options.before=block.signatures[block.signatures.length-1].toString()}}catch(err){if(err instanceof Error&&err.message.includes("skipped")){continue}else{throw err}}}const confirmedSignatureInfo=await this.getConfirmedSignaturesForAddress2(address,options);return confirmedSignatureInfo.map((info=>info.signature))}async getConfirmedSignaturesForAddress2(address,options,commitment){const args=this._buildArgsAtLeastConfirmed([address.toBase58()],commitment,undefined,options);const unsafeRes=await this._rpcRequest("getConfirmedSignaturesForAddress2",args);const res=create(unsafeRes,GetConfirmedSignaturesForAddress2RpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get confirmed signatures for address")}return res.result}async getSignaturesForAddress(address,options,commitment){const args=this._buildArgsAtLeastConfirmed([address.toBase58()],commitment,undefined,options);const unsafeRes=await this._rpcRequest("getSignaturesForAddress",args);const res=create(unsafeRes,GetSignaturesForAddressRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,"failed to get signatures for address")}return res.result}async getAddressLookupTable(accountKey,config){const{context:context,value:accountInfo}=await this.getAccountInfoAndContext(accountKey,config);let value=null;if(accountInfo!==null){value=new AddressLookupTableAccount({key:accountKey,state:AddressLookupTableAccount.deserialize(accountInfo.data)})}return{context:context,value:value}}async getNonceAndContext(nonceAccount,commitmentOrConfig){const{context:context,value:accountInfo}=await this.getAccountInfoAndContext(nonceAccount,commitmentOrConfig);let value=null;if(accountInfo!==null){value=NonceAccount.fromAccountData(accountInfo.data)}return{context:context,value:value}}async getNonce(nonceAccount,commitmentOrConfig){return await this.getNonceAndContext(nonceAccount,commitmentOrConfig).then((x=>x.value)).catch((e=>{throw new Error("failed to get nonce for account "+nonceAccount.toBase58()+": "+e)}))}async requestAirdrop(to,lamports){const unsafeRes=await this._rpcRequest("requestAirdrop",[to.toBase58(),lamports]);const res=create(unsafeRes,RequestAirdropRpcResult);if("error"in res){throw new SolanaJSONRPCError(res.error,`airdrop to ${to.toBase58()} failed`)}return res.result}async _blockhashWithExpiryBlockHeight(disableCache){if(!disableCache){while(this._pollingBlockhash){await sleep(100)}const timeSinceFetch=Date.now()-this._blockhashInfo.lastFetch;const expired=timeSinceFetch>=BLOCKHASH_CACHE_TIMEOUT_MS;if(this._blockhashInfo.latestBlockhash!==null&&!expired){return this._blockhashInfo.latestBlockhash}}return await this._pollNewBlockhash()}async _pollNewBlockhash(){this._pollingBlockhash=true;try{const startTime=Date.now();const cachedLatestBlockhash=this._blockhashInfo.latestBlockhash;const cachedBlockhash=cachedLatestBlockhash?cachedLatestBlockhash.blockhash:null;for(let i=0;i<50;i++){const latestBlockhash=await this.getLatestBlockhash("finalized");if(cachedBlockhash!==latestBlockhash.blockhash){this._blockhashInfo={latestBlockhash:latestBlockhash,lastFetch:Date.now(),transactionSignatures:[],simulatedSignatures:[]};return latestBlockhash}await sleep(MS_PER_SLOT/2)}throw new Error(`Unable to obtain a new blockhash after ${Date.now()-startTime}ms`)}finally{this._pollingBlockhash=false}}async getStakeMinimumDelegation(config){const{commitment:commitment,config:configArg}=extractCommitmentFromConfig(config);const args=this._buildArgs([],commitment,"base64",configArg);const unsafeRes=await this._rpcRequest("getStakeMinimumDelegation",args);const res=create(unsafeRes,jsonRpcResultAndContext(number()));if("error"in res){throw new SolanaJSONRPCError(res.error,`failed to get stake minimum delegation`)}return res.result}async simulateTransaction(transactionOrMessage,configOrSigners,includeAccounts){if("message"in transactionOrMessage){const versionedTx=transactionOrMessage;const wireTransaction=versionedTx.serialize();const encodedTransaction=bufferExports.Buffer.from(wireTransaction).toString("base64");if(Array.isArray(configOrSigners)||includeAccounts!==undefined){throw new Error("Invalid arguments")}const config=configOrSigners||{};config.encoding="base64";if(!("commitment"in config)){config.commitment=this.commitment}if(configOrSigners&&typeof configOrSigners==="object"&&"innerInstructions"in configOrSigners){config.innerInstructions=configOrSigners.innerInstructions}const args=[encodedTransaction,config];const unsafeRes=await this._rpcRequest("simulateTransaction",args);const res=create(unsafeRes,SimulatedTransactionResponseStruct);if("error"in res){throw new Error("failed to simulate transaction: "+res.error.message)}return res.result}let transaction;if(transactionOrMessage instanceof Transaction){let originalTx=transactionOrMessage;transaction=new Transaction;transaction.feePayer=originalTx.feePayer;transaction.instructions=transactionOrMessage.instructions;transaction.nonceInfo=originalTx.nonceInfo;transaction.signatures=originalTx.signatures}else{transaction=Transaction.populate(transactionOrMessage);transaction._message=transaction._json=undefined}if(configOrSigners!==undefined&&!Array.isArray(configOrSigners)){throw new Error("Invalid arguments")}const signers=configOrSigners;if(transaction.nonceInfo&&signers){transaction.sign(...signers)}else{let disableCache=this._disableBlockhashCaching;for(;;){const latestBlockhash=await this._blockhashWithExpiryBlockHeight(disableCache);transaction.lastValidBlockHeight=latestBlockhash.lastValidBlockHeight;transaction.recentBlockhash=latestBlockhash.blockhash;if(!signers)break;transaction.sign(...signers);if(!transaction.signature){throw new Error("!signature")}const signature=transaction.signature.toString("base64");if(!this._blockhashInfo.simulatedSignatures.includes(signature)&&!this._blockhashInfo.transactionSignatures.includes(signature)){this._blockhashInfo.simulatedSignatures.push(signature);break}else{disableCache=true}}}const message=transaction._compile();const signData=message.serialize();const wireTransaction=transaction._serialize(signData);const encodedTransaction=wireTransaction.toString("base64");const config={encoding:"base64",commitment:this.commitment};if(includeAccounts){const addresses=(Array.isArray(includeAccounts)?includeAccounts:message.nonProgramIds()).map((key=>key.toBase58()));config["accounts"]={encoding:"base64",addresses:addresses}}if(signers){config.sigVerify=true}if(configOrSigners&&typeof configOrSigners==="object"&&"innerInstructions"in configOrSigners){config.innerInstructions=configOrSigners.innerInstructions}const args=[encodedTransaction,config];const unsafeRes=await this._rpcRequest("simulateTransaction",args);const res=create(unsafeRes,SimulatedTransactionResponseStruct);if("error"in res){let logs;if("data"in res.error){logs=res.error.data.logs;if(logs&&Array.isArray(logs)){const traceIndent="\n ";const logTrace=traceIndent+logs.join(traceIndent);console.error(res.error.message,logTrace)}}throw new SendTransactionError({action:"simulate",signature:"",transactionMessage:res.error.message,logs:logs})}return res.result}async sendTransaction(transaction,signersOrOptions,options){if("version"in transaction){if(signersOrOptions&&Array.isArray(signersOrOptions)){throw new Error("Invalid arguments")}const wireTransaction=transaction.serialize();return await this.sendRawTransaction(wireTransaction,signersOrOptions)}if(signersOrOptions===undefined||!Array.isArray(signersOrOptions)){throw new Error("Invalid arguments")}const signers=signersOrOptions;if(transaction.nonceInfo){transaction.sign(...signers)}else{let disableCache=this._disableBlockhashCaching;for(;;){const latestBlockhash=await this._blockhashWithExpiryBlockHeight(disableCache);transaction.lastValidBlockHeight=latestBlockhash.lastValidBlockHeight;transaction.recentBlockhash=latestBlockhash.blockhash;transaction.sign(...signers);if(!transaction.signature){throw new Error("!signature")}const signature=transaction.signature.toString("base64");if(!this._blockhashInfo.transactionSignatures.includes(signature)){this._blockhashInfo.transactionSignatures.push(signature);break}else{disableCache=true}}}const wireTransaction=transaction.serialize();return await this.sendRawTransaction(wireTransaction,options)}async sendRawTransaction(rawTransaction,options){const encodedTransaction=toBuffer(rawTransaction).toString("base64");const result=await this.sendEncodedTransaction(encodedTransaction,options);return result}async sendEncodedTransaction(encodedTransaction,options){const config={encoding:"base64"};const skipPreflight=options&&options.skipPreflight;const preflightCommitment=skipPreflight===true?"processed":options&&options.preflightCommitment||this.commitment;if(options&&options.maxRetries!=null){config.maxRetries=options.maxRetries}if(options&&options.minContextSlot!=null){config.minContextSlot=options.minContextSlot}if(skipPreflight){config.skipPreflight=skipPreflight}if(preflightCommitment){config.preflightCommitment=preflightCommitment}const args=[encodedTransaction,config];const unsafeRes=await this._rpcRequest("sendTransaction",args);const res=create(unsafeRes,SendTransactionRpcResult);if("error"in res){let logs=undefined;if("data"in res.error){logs=res.error.data.logs}throw new SendTransactionError({action:skipPreflight?"send":"simulate",signature:"",transactionMessage:res.error.message,logs:logs})}return res.result}_wsOnOpen(){this._rpcWebSocketConnected=true;this._rpcWebSocketHeartbeat=setInterval((()=>{(async()=>{try{await this._rpcWebSocket.notify("ping")}catch{}})()}),5e3);this._updateSubscriptions()}_wsOnError(err){this._rpcWebSocketConnected=false;console.error("ws error:",err.message)}_wsOnClose(code){this._rpcWebSocketConnected=false;this._rpcWebSocketGeneration=(this._rpcWebSocketGeneration+1)%Number.MAX_SAFE_INTEGER;if(this._rpcWebSocketIdleTimeout){clearTimeout(this._rpcWebSocketIdleTimeout);this._rpcWebSocketIdleTimeout=null}if(this._rpcWebSocketHeartbeat){clearInterval(this._rpcWebSocketHeartbeat);this._rpcWebSocketHeartbeat=null}if(code===1e3){this._updateSubscriptions();return}this._subscriptionCallbacksByServerSubscriptionId={};Object.entries(this._subscriptionsByHash).forEach((([hash,subscription])=>{this._setSubscription(hash,{...subscription,state:"pending"})}))}_setSubscription(hash,nextSubscription){const prevState=this._subscriptionsByHash[hash]?.state;this._subscriptionsByHash[hash]=nextSubscription;if(prevState!==nextSubscription.state){const stateChangeCallbacks=this._subscriptionStateChangeCallbacksByHash[hash];if(stateChangeCallbacks){stateChangeCallbacks.forEach((cb=>{try{cb(nextSubscription.state)}catch{}}))}}}_onSubscriptionStateChange(clientSubscriptionId,callback){const hash=this._subscriptionHashByClientSubscriptionId[clientSubscriptionId];if(hash==null){return()=>{}}const stateChangeCallbacks=this._subscriptionStateChangeCallbacksByHash[hash]||=new Set;stateChangeCallbacks.add(callback);return()=>{stateChangeCallbacks.delete(callback);if(stateChangeCallbacks.size===0){delete this._subscriptionStateChangeCallbacksByHash[hash]}}}async _updateSubscriptions(){if(Object.keys(this._subscriptionsByHash).length===0){if(this._rpcWebSocketConnected){this._rpcWebSocketConnected=false;this._rpcWebSocketIdleTimeout=setTimeout((()=>{this._rpcWebSocketIdleTimeout=null;try{this._rpcWebSocket.close()}catch(err){if(err instanceof Error){console.log(`Error when closing socket connection: ${err.message}`)}}}),500)}return}if(this._rpcWebSocketIdleTimeout!==null){clearTimeout(this._rpcWebSocketIdleTimeout);this._rpcWebSocketIdleTimeout=null;this._rpcWebSocketConnected=true}if(!this._rpcWebSocketConnected){this._rpcWebSocket.connect();return}const activeWebSocketGeneration=this._rpcWebSocketGeneration;const isCurrentConnectionStillActive=()=>activeWebSocketGeneration===this._rpcWebSocketGeneration;await Promise.all(Object.keys(this._subscriptionsByHash).map((async hash=>{const subscription=this._subscriptionsByHash[hash];if(subscription===undefined){return}switch(subscription.state){case"pending":case"unsubscribed":if(subscription.callbacks.size===0){delete this._subscriptionsByHash[hash];if(subscription.state==="unsubscribed"){delete this._subscriptionCallbacksByServerSubscriptionId[subscription.serverSubscriptionId]}await this._updateSubscriptions();return}await(async()=>{const{args:args,method:method}=subscription;try{this._setSubscription(hash,{...subscription,state:"subscribing"});const serverSubscriptionId=await this._rpcWebSocket.call(method,args);this._setSubscription(hash,{...subscription,serverSubscriptionId:serverSubscriptionId,state:"subscribed"});this._subscriptionCallbacksByServerSubscriptionId[serverSubscriptionId]=subscription.callbacks;await this._updateSubscriptions()}catch(e){console.error(`Received ${e instanceof Error?"":"JSON-RPC "}error calling \`${method}\``,{args:args,error:e});if(!isCurrentConnectionStillActive()){return}this._setSubscription(hash,{...subscription,state:"pending"});await this._updateSubscriptions()}})();break;case"subscribed":if(subscription.callbacks.size===0){await(async()=>{const{serverSubscriptionId:serverSubscriptionId,unsubscribeMethod:unsubscribeMethod}=subscription;if(this._subscriptionsAutoDisposedByRpc.has(serverSubscriptionId)){this._subscriptionsAutoDisposedByRpc.delete(serverSubscriptionId)}else{this._setSubscription(hash,{...subscription,state:"unsubscribing"});this._setSubscription(hash,{...subscription,state:"unsubscribing"});try{await this._rpcWebSocket.call(unsubscribeMethod,[serverSubscriptionId])}catch(e){if(e instanceof Error){console.error(`${unsubscribeMethod} error:`,e.message)}if(!isCurrentConnectionStillActive()){return}this._setSubscription(hash,{...subscription,state:"subscribed"});await this._updateSubscriptions();return}}this._setSubscription(hash,{...subscription,state:"unsubscribed"});await this._updateSubscriptions()})()}break}})))}_handleServerNotification(serverSubscriptionId,callbackArgs){const callbacks=this._subscriptionCallbacksByServerSubscriptionId[serverSubscriptionId];if(callbacks===undefined){return}callbacks.forEach((cb=>{try{cb(...callbackArgs)}catch(e){console.error(e)}}))}_wsOnAccountNotification(notification){const{result:result,subscription:subscription}=create(notification,AccountNotificationResult);this._handleServerNotification(subscription,[result.value,result.context])}_makeSubscription(subscriptionConfig,args){const clientSubscriptionId=this._nextClientSubscriptionId++;const hash=fastStableStringify([subscriptionConfig.method,args]);const existingSubscription=this._subscriptionsByHash[hash];if(existingSubscription===undefined){this._subscriptionsByHash[hash]={...subscriptionConfig,args:args,callbacks:new Set([subscriptionConfig.callback]),state:"pending"}}else{existingSubscription.callbacks.add(subscriptionConfig.callback)}this._subscriptionHashByClientSubscriptionId[clientSubscriptionId]=hash;this._subscriptionDisposeFunctionsByClientSubscriptionId[clientSubscriptionId]=async()=>{delete this._subscriptionDisposeFunctionsByClientSubscriptionId[clientSubscriptionId];delete this._subscriptionHashByClientSubscriptionId[clientSubscriptionId];const subscription=this._subscriptionsByHash[hash];assert$1(subscription!==undefined,`Could not find a \`Subscription\` when tearing down client subscription #${clientSubscriptionId}`);subscription.callbacks.delete(subscriptionConfig.callback);await this._updateSubscriptions()};this._updateSubscriptions();return clientSubscriptionId}onAccountChange(publicKey,callback,commitmentOrConfig){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([publicKey.toBase58()],commitment||this._commitment||"finalized","base64",config);return this._makeSubscription({callback:callback,method:"accountSubscribe",unsubscribeMethod:"accountUnsubscribe"},args)}async removeAccountChangeListener(clientSubscriptionId){await this._unsubscribeClientSubscription(clientSubscriptionId,"account change")}_wsOnProgramAccountNotification(notification){const{result:result,subscription:subscription}=create(notification,ProgramAccountNotificationResult);this._handleServerNotification(subscription,[{accountId:result.value.pubkey,accountInfo:result.value.account},result.context])}onProgramAccountChange(programId,callback,commitmentOrConfig,maybeFilters){const{commitment:commitment,config:config}=extractCommitmentFromConfig(commitmentOrConfig);const args=this._buildArgs([programId.toBase58()],commitment||this._commitment||"finalized","base64",config?config:maybeFilters?{filters:applyDefaultMemcmpEncodingToFilters(maybeFilters)}:undefined);return this._makeSubscription({callback:callback,method:"programSubscribe",unsubscribeMethod:"programUnsubscribe"},args)}async removeProgramAccountChangeListener(clientSubscriptionId){await this._unsubscribeClientSubscription(clientSubscriptionId,"program account change")}onLogs(filter,callback,commitment){const args=this._buildArgs([typeof filter==="object"?{mentions:[filter.toString()]}:filter],commitment||this._commitment||"finalized");return this._makeSubscription({callback:callback,method:"logsSubscribe",unsubscribeMethod:"logsUnsubscribe"},args)}async removeOnLogsListener(clientSubscriptionId){await this._unsubscribeClientSubscription(clientSubscriptionId,"logs")}_wsOnLogsNotification(notification){const{result:result,subscription:subscription}=create(notification,LogsNotificationResult);this._handleServerNotification(subscription,[result.value,result.context])}_wsOnSlotNotification(notification){const{result:result,subscription:subscription}=create(notification,SlotNotificationResult);this._handleServerNotification(subscription,[result])}onSlotChange(callback){return this._makeSubscription({callback:callback,method:"slotSubscribe",unsubscribeMethod:"slotUnsubscribe"},[])}async removeSlotChangeListener(clientSubscriptionId){await this._unsubscribeClientSubscription(clientSubscriptionId,"slot change")}_wsOnSlotUpdatesNotification(notification){const{result:result,subscription:subscription}=create(notification,SlotUpdateNotificationResult);this._handleServerNotification(subscription,[result])}onSlotUpdate(callback){return this._makeSubscription({callback:callback,method:"slotsUpdatesSubscribe",unsubscribeMethod:"slotsUpdatesUnsubscribe"},[])}async removeSlotUpdateListener(clientSubscriptionId){await this._unsubscribeClientSubscription(clientSubscriptionId,"slot update")}async _unsubscribeClientSubscription(clientSubscriptionId,subscriptionName){const dispose=this._subscriptionDisposeFunctionsByClientSubscriptionId[clientSubscriptionId];if(dispose){await dispose()}else{console.warn("Ignored unsubscribe request because an active subscription with id "+`\`${clientSubscriptionId}\` for '${subscriptionName}' events `+"could not be found.")}}_buildArgs(args,override,encoding,extra){const commitment=override||this._commitment;if(commitment||encoding||extra){let options={};if(encoding){options.encoding=encoding}if(commitment){options.commitment=commitment}if(extra){options=Object.assign(options,extra)}args.push(options)}return args}_buildArgsAtLeastConfirmed(args,override,encoding,extra){const commitment=override||this._commitment;if(commitment&&!["confirmed","finalized"].includes(commitment)){throw new Error("Using Connection with default commitment: `"+this._commitment+"`, but method requires at least `confirmed`")}return this._buildArgs(args,override,encoding,extra)}_wsOnSignatureNotification(notification){const{result:result,subscription:subscription}=create(notification,SignatureNotificationResult);if(result.value!=="receivedSignature"){this._subscriptionsAutoDisposedByRpc.add(subscription)}this._handleServerNotification(subscription,result.value==="receivedSignature"?[{type:"received"},result.context]:[{type:"status",result:result.value},result.context])}onSignature(signature,callback,commitment){const args=this._buildArgs([signature],commitment||this._commitment||"finalized");const clientSubscriptionId=this._makeSubscription({callback:(notification,context)=>{if(notification.type==="status"){callback(notification.result,context);try{this.removeSignatureListener(clientSubscriptionId)}catch(_err){}}},method:"signatureSubscribe",unsubscribeMethod:"signatureUnsubscribe"},args);return clientSubscriptionId}onSignatureWithOptions(signature,callback,options){const{commitment:commitment,...extra}={...options,commitment:options&&options.commitment||this._commitment||"finalized"};const args=this._buildArgs([signature],commitment,undefined,extra);const clientSubscriptionId=this._makeSubscription({callback:(notification,context)=>{callback(notification,context);try{this.removeSignatureListener(clientSubscriptionId)}catch(_err){}},method:"signatureSubscribe",unsubscribeMethod:"signatureUnsubscribe"},args);return clientSubscriptionId}async removeSignatureListener(clientSubscriptionId){await this._unsubscribeClientSubscription(clientSubscriptionId,"signature result")}_wsOnRootNotification(notification){const{result:result,subscription:subscription}=create(notification,RootNotificationResult);this._handleServerNotification(subscription,[result])}onRootChange(callback){return this._makeSubscription({callback:callback,method:"rootSubscribe",unsubscribeMethod:"rootUnsubscribe"},[])}async removeRootChangeListener(clientSubscriptionId){await this._unsubscribeClientSubscription(clientSubscriptionId,"root change")}}class Keypair{constructor(keypair){this._keypair=void 0;this._keypair=keypair??generateKeypair()}static generate(){return new Keypair(generateKeypair())}static fromSecretKey(secretKey,options){if(secretKey.byteLength!==64){throw new Error("bad secret key size")}const publicKey=secretKey.slice(32,64);if(!options||!options.skipValidation){const privateScalar=secretKey.slice(0,32);const computedPublicKey=getPublicKey(privateScalar);for(let ii=0;ii<32;ii++){if(publicKey[ii]!==computedPublicKey[ii]){throw new Error("provided secretKey is invalid")}}}return new Keypair({publicKey:publicKey,secretKey:secretKey})}static fromSeed(seed){const publicKey=getPublicKey(seed);const secretKey=new Uint8Array(64);secretKey.set(seed);secretKey.set(publicKey,32);return new Keypair({publicKey:publicKey,secretKey:secretKey})}get publicKey(){return new PublicKey(this._keypair.publicKey)}get secretKey(){return new Uint8Array(this._keypair.secretKey)}}const LOOKUP_TABLE_INSTRUCTION_LAYOUTS=Object.freeze({CreateLookupTable:{index:0,layout:LayoutExports.struct([LayoutExports.u32("instruction"),u64("recentSlot"),LayoutExports.u8("bumpSeed")])},FreezeLookupTable:{index:1,layout:LayoutExports.struct([LayoutExports.u32("instruction")])},ExtendLookupTable:{index:2,layout:LayoutExports.struct([LayoutExports.u32("instruction"),u64(),LayoutExports.seq(publicKey(),LayoutExports.offset(LayoutExports.u32(),-8),"addresses")])},DeactivateLookupTable:{index:3,layout:LayoutExports.struct([LayoutExports.u32("instruction")])},CloseLookupTable:{index:4,layout:LayoutExports.struct([LayoutExports.u32("instruction")])}});class AddressLookupTableInstruction{constructor(){}static decodeInstructionType(instruction){this.checkProgramId(instruction.programId);const instructionTypeLayout=LayoutExports.u32("instruction");const index=instructionTypeLayout.decode(instruction.data);let type;for(const[layoutType,layout]of Object.entries(LOOKUP_TABLE_INSTRUCTION_LAYOUTS)){if(layout.index==index){type=layoutType;break}}if(!type){throw new Error("Invalid Instruction. Should be a LookupTable Instruction")}return type}static decodeCreateLookupTable(instruction){this.checkProgramId(instruction.programId);this.checkKeysLength(instruction.keys,4);const{recentSlot:recentSlot}=decodeData$1(LOOKUP_TABLE_INSTRUCTION_LAYOUTS.CreateLookupTable,instruction.data);return{authority:instruction.keys[1].pubkey,payer:instruction.keys[2].pubkey,recentSlot:Number(recentSlot)}}static decodeExtendLookupTable(instruction){this.checkProgramId(instruction.programId);if(instruction.keys.length<2){throw new Error(`invalid instruction; found ${instruction.keys.length} keys, expected at least 2`)}const{addresses:addresses}=decodeData$1(LOOKUP_TABLE_INSTRUCTION_LAYOUTS.ExtendLookupTable,instruction.data);return{lookupTable:instruction.keys[0].pubkey,authority:instruction.keys[1].pubkey,payer:instruction.keys.length>2?instruction.keys[2].pubkey:undefined,addresses:addresses.map((buffer=>new PublicKey(buffer)))}}static decodeCloseLookupTable(instruction){this.checkProgramId(instruction.programId);this.checkKeysLength(instruction.keys,3);return{lookupTable:instruction.keys[0].pubkey,authority:instruction.keys[1].pubkey,recipient:instruction.keys[2].pubkey}}static decodeFreezeLookupTable(instruction){this.checkProgramId(instruction.programId);this.checkKeysLength(instruction.keys,2);return{lookupTable:instruction.keys[0].pubkey,authority:instruction.keys[1].pubkey}}static decodeDeactivateLookupTable(instruction){this.checkProgramId(instruction.programId);this.checkKeysLength(instruction.keys,2);return{lookupTable:instruction.keys[0].pubkey,authority:instruction.keys[1].pubkey}}static checkProgramId(programId){if(!programId.equals(AddressLookupTableProgram.programId)){throw new Error("invalid instruction; programId is not AddressLookupTable Program")}}static checkKeysLength(keys,expectedLength){if(keys.lengthaddr.toBytes()))});const keys=[{pubkey:params.lookupTable,isSigner:false,isWritable:true},{pubkey:params.authority,isSigner:true,isWritable:false}];if(params.payer){keys.push({pubkey:params.payer,isSigner:true,isWritable:true},{pubkey:SystemProgram.programId,isSigner:false,isWritable:false})}return new TransactionInstruction({programId:this.programId,keys:keys,data:data})}static deactivateLookupTable(params){const type=LOOKUP_TABLE_INSTRUCTION_LAYOUTS.DeactivateLookupTable;const data=encodeData(type);const keys=[{pubkey:params.lookupTable,isSigner:false,isWritable:true},{pubkey:params.authority,isSigner:true,isWritable:false}];return new TransactionInstruction({programId:this.programId,keys:keys,data:data})}static closeLookupTable(params){const type=LOOKUP_TABLE_INSTRUCTION_LAYOUTS.CloseLookupTable;const data=encodeData(type);const keys=[{pubkey:params.lookupTable,isSigner:false,isWritable:true},{pubkey:params.authority,isSigner:true,isWritable:false},{pubkey:params.recipient,isSigner:false,isWritable:true}];return new TransactionInstruction({programId:this.programId,keys:keys,data:data})}}AddressLookupTableProgram.programId=new PublicKey("AddressLookupTab1e1111111111111111111111111");class ComputeBudgetInstruction{constructor(){}static decodeInstructionType(instruction){this.checkProgramId(instruction.programId);const instructionTypeLayout=LayoutExports.u8("instruction");const typeIndex=instructionTypeLayout.decode(instruction.data);let type;for(const[ixType,layout]of Object.entries(COMPUTE_BUDGET_INSTRUCTION_LAYOUTS)){if(layout.index==typeIndex){type=ixType;break}}if(!type){throw new Error("Instruction type incorrect; not a ComputeBudgetInstruction")}return type}static decodeRequestUnits(instruction){this.checkProgramId(instruction.programId);const{units:units,additionalFee:additionalFee}=decodeData$1(COMPUTE_BUDGET_INSTRUCTION_LAYOUTS.RequestUnits,instruction.data);return{units:units,additionalFee:additionalFee}}static decodeRequestHeapFrame(instruction){this.checkProgramId(instruction.programId);const{bytes:bytes}=decodeData$1(COMPUTE_BUDGET_INSTRUCTION_LAYOUTS.RequestHeapFrame,instruction.data);return{bytes:bytes}}static decodeSetComputeUnitLimit(instruction){this.checkProgramId(instruction.programId);const{units:units}=decodeData$1(COMPUTE_BUDGET_INSTRUCTION_LAYOUTS.SetComputeUnitLimit,instruction.data);return{units:units}}static decodeSetComputeUnitPrice(instruction){this.checkProgramId(instruction.programId);const{microLamports:microLamports}=decodeData$1(COMPUTE_BUDGET_INSTRUCTION_LAYOUTS.SetComputeUnitPrice,instruction.data);return{microLamports:microLamports}}static checkProgramId(programId){if(!programId.equals(ComputeBudgetProgram.programId)){throw new Error("invalid instruction; programId is not ComputeBudgetProgram")}}}const COMPUTE_BUDGET_INSTRUCTION_LAYOUTS=Object.freeze({RequestUnits:{index:0,layout:LayoutExports.struct([LayoutExports.u8("instruction"),LayoutExports.u32("units"),LayoutExports.u32("additionalFee")])},RequestHeapFrame:{index:1,layout:LayoutExports.struct([LayoutExports.u8("instruction"),LayoutExports.u32("bytes")])},SetComputeUnitLimit:{index:2,layout:LayoutExports.struct([LayoutExports.u8("instruction"),LayoutExports.u32("units")])},SetComputeUnitPrice:{index:3,layout:LayoutExports.struct([LayoutExports.u8("instruction"),u64("microLamports")])}});class ComputeBudgetProgram{constructor(){}static requestUnits(params){const type=COMPUTE_BUDGET_INSTRUCTION_LAYOUTS.RequestUnits;const data=encodeData(type,params);return new TransactionInstruction({keys:[],programId:this.programId,data:data})}static requestHeapFrame(params){const type=COMPUTE_BUDGET_INSTRUCTION_LAYOUTS.RequestHeapFrame;const data=encodeData(type,params);return new TransactionInstruction({keys:[],programId:this.programId,data:data})}static setComputeUnitLimit(params){const type=COMPUTE_BUDGET_INSTRUCTION_LAYOUTS.SetComputeUnitLimit;const data=encodeData(type,params);return new TransactionInstruction({keys:[],programId:this.programId,data:data})}static setComputeUnitPrice(params){const type=COMPUTE_BUDGET_INSTRUCTION_LAYOUTS.SetComputeUnitPrice;const data=encodeData(type,{microLamports:BigInt(params.microLamports)});return new TransactionInstruction({keys:[],programId:this.programId,data:data})}}ComputeBudgetProgram.programId=new PublicKey("ComputeBudget111111111111111111111111111111");const PRIVATE_KEY_BYTES$1=64;const PUBLIC_KEY_BYTES$1=32;const SIGNATURE_BYTES=64;const ED25519_INSTRUCTION_LAYOUT=LayoutExports.struct([LayoutExports.u8("numSignatures"),LayoutExports.u8("padding"),LayoutExports.u16("signatureOffset"),LayoutExports.u16("signatureInstructionIndex"),LayoutExports.u16("publicKeyOffset"),LayoutExports.u16("publicKeyInstructionIndex"),LayoutExports.u16("messageDataOffset"),LayoutExports.u16("messageDataSize"),LayoutExports.u16("messageInstructionIndex")]);class Ed25519Program{constructor(){}static createInstructionWithPublicKey(params){const{publicKey:publicKey,message:message,signature:signature,instructionIndex:instructionIndex}=params;assert$1(publicKey.length===PUBLIC_KEY_BYTES$1,`Public Key must be ${PUBLIC_KEY_BYTES$1} bytes but received ${publicKey.length} bytes`);assert$1(signature.length===SIGNATURE_BYTES,`Signature must be ${SIGNATURE_BYTES} bytes but received ${signature.length} bytes`);const publicKeyOffset=ED25519_INSTRUCTION_LAYOUT.span;const signatureOffset=publicKeyOffset+publicKey.length;const messageDataOffset=signatureOffset+signature.length;const numSignatures=1;const instructionData=bufferExports.Buffer.alloc(messageDataOffset+message.length);const index=instructionIndex==null?65535:instructionIndex;ED25519_INSTRUCTION_LAYOUT.encode({numSignatures:numSignatures,padding:0,signatureOffset:signatureOffset,signatureInstructionIndex:index,publicKeyOffset:publicKeyOffset,publicKeyInstructionIndex:index,messageDataOffset:messageDataOffset,messageDataSize:message.length,messageInstructionIndex:index},instructionData);instructionData.fill(publicKey,publicKeyOffset);instructionData.fill(signature,signatureOffset);instructionData.fill(message,messageDataOffset);return new TransactionInstruction({keys:[],programId:Ed25519Program.programId,data:instructionData})}static createInstructionWithPrivateKey(params){const{privateKey:privateKey,message:message,instructionIndex:instructionIndex}=params;assert$1(privateKey.length===PRIVATE_KEY_BYTES$1,`Private key must be ${PRIVATE_KEY_BYTES$1} bytes but received ${privateKey.length} bytes`);try{const keypair=Keypair.fromSecretKey(privateKey);const publicKey=keypair.publicKey.toBytes();const signature=sign(message,keypair.secretKey);return this.createInstructionWithPublicKey({publicKey:publicKey,message:message,signature:signature,instructionIndex:instructionIndex})}catch(error){throw new Error(`Error creating instruction; ${error}`)}}}Ed25519Program.programId=new PublicKey("Ed25519SigVerify111111111111111111111111111");const U32_MASK64=BigInt(2**32-1);const _32n=BigInt(32);function fromBig(n,le=false){if(le)return{h:Number(n&U32_MASK64),l:Number(n>>_32n&U32_MASK64)};return{h:Number(n>>_32n&U32_MASK64)|0,l:Number(n&U32_MASK64)|0}}function split(lst,le=false){let Ah=new Uint32Array(lst.length);let Al=new Uint32Array(lst.length);for(let i=0;ih<>>32-s;const rotlSL=(h,l,s)=>l<>>32-s;const rotlBH=(h,l,s)=>l<>>64-s;const rotlBL=(h,l,s)=>h<>>64-s;const SHA3_PI=[];const SHA3_ROTL=[];const _SHA3_IOTA=[];const _0n$1=BigInt(0);const _1n$2=BigInt(1);const _2n$1=BigInt(2);const _7n=BigInt(7);const _256n=BigInt(256);const _0x71n=BigInt(113);for(let round=0,R=_1n$2,x=1,y=0;round<24;round++){[x,y]=[y,(2*x+3*y)%5];SHA3_PI.push(2*(5*y+x));SHA3_ROTL.push((round+1)*(round+2)/2%64);let t=_0n$1;for(let j=0;j<7;j++){R=(R<<_1n$2^(R>>_7n)*_0x71n)%_256n;if(R&_2n$1)t^=_1n$2<<(_1n$2<s>32?rotlBH(h,l,s):rotlSH(h,l,s);const rotlL=(h,l,s)=>s>32?rotlBL(h,l,s):rotlSL(h,l,s);function keccakP(s,rounds=24){const B=new Uint32Array(5*2);for(let round=24-rounds;round<24;round++){for(let x=0;x<10;x++)B[x]=s[x]^s[x+10]^s[x+20]^s[x+30]^s[x+40];for(let x=0;x<10;x+=2){const idx1=(x+8)%10;const idx0=(x+2)%10;const B0=B[idx0];const B1=B[idx0+1];const Th=rotlH(B0,B1,1)^B[idx1];const Tl=rotlL(B0,B1,1)^B[idx1+1];for(let y=0;y<50;y+=10){s[x+y]^=Th;s[x+y+1]^=Tl}}let curH=s[2];let curL=s[3];for(let t=0;t<24;t++){const shift=SHA3_ROTL[t];const Th=rotlH(curH,curL,shift);const Tl=rotlL(curH,curL,shift);const PI=SHA3_PI[t];curH=s[PI];curL=s[PI+1];s[PI]=Th;s[PI+1]=Tl}for(let y=0;y<50;y+=10){for(let x=0;x<10;x++)B[x]=s[y+x];for(let x=0;x<10;x++)s[y+x]^=~B[(x+2)%10]&B[(x+4)%10]}s[0]^=SHA3_IOTA_H[round];s[1]^=SHA3_IOTA_L[round]}B.fill(0)}class Keccak extends Hash{constructor(blockLen,suffix,outputLen,enableXOF=false,rounds=24){super();this.blockLen=blockLen;this.suffix=suffix;this.outputLen=outputLen;this.enableXOF=enableXOF;this.rounds=rounds;this.pos=0;this.posOut=0;this.finished=false;this.destroyed=false;anumber(outputLen);if(0>=this.blockLen||this.blockLen>=200)throw new Error("Sha3 supports only keccak-f1600 function");this.state=new Uint8Array(200);this.state32=u32(this.state)}keccak(){if(!isLE)byteSwap32(this.state32);keccakP(this.state32,this.rounds);if(!isLE)byteSwap32(this.state32);this.posOut=0;this.pos=0}update(data){aexists(this);const{blockLen:blockLen,state:state}=this;data=toBytes(data);const len=data.length;for(let pos=0;pos=blockLen)this.keccak();const take=Math.min(blockLen-this.posOut,len-pos);out.set(bufferOut.subarray(this.posOut,this.posOut+take),pos);this.posOut+=take;pos+=take}return out}xofInto(out){if(!this.enableXOF)throw new Error("XOF is not possible for this instance");return this.writeInto(out)}xof(bytes){anumber(bytes);return this.xofInto(new Uint8Array(bytes))}digestInto(out){aoutput(out,this);if(this.finished)throw new Error("digest() was already called");this.writeInto(out);this.destroy();return out}digest(){return this.digestInto(new Uint8Array(this.outputLen))}destroy(){this.destroyed=true;this.state.fill(0)}_cloneInto(to){const{blockLen:blockLen,suffix:suffix,outputLen:outputLen,rounds:rounds,enableXOF:enableXOF}=this;to||(to=new Keccak(blockLen,suffix,outputLen,enableXOF,rounds));to.state32.set(this.state32);to.pos=this.pos;to.posOut=this.posOut;to.finished=this.finished;to.rounds=rounds;to.suffix=suffix;to.outputLen=outputLen;to.enableXOF=enableXOF;to.destroyed=this.destroyed;return to}}const gen=(suffix,blockLen,outputLen)=>wrapConstructor((()=>new Keccak(blockLen,suffix,outputLen)));const keccak_256=gen(1,136,256/8);const SHA256_K=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]);const SHA256_IV=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]);const SHA256_W=new Uint32Array(64);class SHA256 extends HashMD$1{constructor(){super(64,32,8,false);this.A=SHA256_IV[0]|0;this.B=SHA256_IV[1]|0;this.C=SHA256_IV[2]|0;this.D=SHA256_IV[3]|0;this.E=SHA256_IV[4]|0;this.F=SHA256_IV[5]|0;this.G=SHA256_IV[6]|0;this.H=SHA256_IV[7]|0}get(){const{A:A,B:B,C:C,D:D,E:E,F:F,G:G,H:H}=this;return[A,B,C,D,E,F,G,H]}set(A,B,C,D,E,F,G,H){this.A=A|0;this.B=B|0;this.C=C|0;this.D=D|0;this.E=E|0;this.F=F|0;this.G=G|0;this.H=H|0}process(view,offset){for(let i=0;i<16;i++,offset+=4)SHA256_W[i]=view.getUint32(offset,false);for(let i=16;i<64;i++){const W15=SHA256_W[i-15];const W2=SHA256_W[i-2];const s0=rotr$1(W15,7)^rotr$1(W15,18)^W15>>>3;const s1=rotr$1(W2,17)^rotr$1(W2,19)^W2>>>10;SHA256_W[i]=s1+SHA256_W[i-7]+s0+SHA256_W[i-16]|0}let{A:A,B:B,C:C,D:D,E:E,F:F,G:G,H:H}=this;for(let i=0;i<64;i++){const sigma1=rotr$1(E,6)^rotr$1(E,11)^rotr$1(E,25);const T1=H+sigma1+Chi$1(E,F,G)+SHA256_K[i]+SHA256_W[i]|0;const sigma0=rotr$1(A,2)^rotr$1(A,13)^rotr$1(A,22);const T2=sigma0+Maj$1(A,B,C)|0;H=G;G=F;F=E;E=D+T1|0;D=C;C=B;B=A;A=T1+T2|0}A=A+this.A|0;B=B+this.B|0;C=C+this.C|0;D=D+this.D|0;E=E+this.E|0;F=F+this.F|0;G=G+this.G|0;H=H+this.H|0;this.set(A,B,C,D,E,F,G,H)}roundClean(){SHA256_W.fill(0)}destroy(){this.set(0,0,0,0,0,0,0,0);this.buffer.fill(0)}}const sha256=wrapConstructor$1((()=>new SHA256));class HMAC extends Hash$1{constructor(hash,_key){super();this.finished=false;this.destroyed=false;ahash(hash);const key=toBytes$1(_key);this.iHash=hash.create();if(typeof this.iHash.update!=="function")throw new Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen;this.outputLen=this.iHash.outputLen;const blockLen=this.blockLen;const pad=new Uint8Array(blockLen);pad.set(key.length>blockLen?hash.create().update(key).digest():key);for(let i=0;inew HMAC(hash,key).update(message).digest();hmac.create=(hash,key)=>new HMAC(hash,key) +/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */;function validateSigVerOpts(opts){if(opts.lowS!==undefined)abool("lowS",opts.lowS);if(opts.prehash!==undefined)abool("prehash",opts.prehash)}function validatePointOpts(curve){const opts=validateBasic(curve);validateObject(opts,{a:"field",b:"field"},{allowedPrivateKeyLengths:"array",wrapPrivateKey:"boolean",isTorsionFree:"function",clearCofactor:"function",allowInfinityPoint:"boolean",fromBytes:"function",toBytes:"function"});const{endo:endo,Fp:Fp,a:a}=opts;if(endo){if(!Fp.eql(a,Fp.ZERO)){throw new Error("invalid endomorphism, can only be defined for Koblitz curves that have a=0")}if(typeof endo!=="object"||typeof endo.beta!=="bigint"||typeof endo.splitScalar!=="function"){throw new Error("invalid endomorphism, expected beta: bigint and splitScalar: function")}}return Object.freeze({...opts})}const{bytesToNumberBE:b2n,hexToBytes:h2b}=ut;const DER={Err:class DERErr extends Error{constructor(m=""){super(m)}},_tlv:{encode:(tag,data)=>{const{Err:E}=DER;if(tag<0||tag>256)throw new E("tlv.encode: wrong tag");if(data.length&1)throw new E("tlv.encode: unpadded data");const dataLen=data.length/2;const len=numberToHexUnpadded(dataLen);if(len.length/2&128)throw new E("tlv.encode: long form length too big");const lenLen=dataLen>127?numberToHexUnpadded(len.length/2|128):"";const t=numberToHexUnpadded(tag);return t+lenLen+len+data},decode(tag,data){const{Err:E}=DER;let pos=0;if(tag<0||tag>256)throw new E("tlv.encode: wrong tag");if(data.length<2||data[pos++]!==tag)throw new E("tlv.decode: wrong tlv");const first=data[pos++];const isLong=!!(first&128);let length=0;if(!isLong)length=first;else{const lenLen=first&127;if(!lenLen)throw new E("tlv.decode(long): indefinite length not supported");if(lenLen>4)throw new E("tlv.decode(long): byte length is too big");const lengthBytes=data.subarray(pos,pos+lenLen);if(lengthBytes.length!==lenLen)throw new E("tlv.decode: length bytes not complete");if(lengthBytes[0]===0)throw new E("tlv.decode(long): zero leftmost byte");for(const b of lengthBytes)length=length<<8|b;pos+=lenLen;if(length<128)throw new E("tlv.decode(long): not minimal encoding")}const v=data.subarray(pos,pos+length);if(v.length!==length)throw new E("tlv.decode: wrong value length");return{v:v,l:data.subarray(pos+length)}}},_int:{encode(num){const{Err:E}=DER;if(num<_0n)throw new E("integer: negative integers are not allowed");let hex=numberToHexUnpadded(num);if(Number.parseInt(hex[0],16)&8)hex="00"+hex;if(hex.length&1)throw new E("unexpected DER parsing assertion: unpadded hex");return hex},decode(data){const{Err:E}=DER;if(data[0]&128)throw new E("invalid signature integer: negative");if(data[0]===0&&!(data[1]&128))throw new E("invalid signature integer: unnecessary leading zero");return b2n(data)}},toSig(hex){const{Err:E,_int:int,_tlv:tlv}=DER;const data=typeof hex==="string"?h2b(hex):hex;abytes$1(data);const{v:seqBytes,l:seqLeftBytes}=tlv.decode(48,data);if(seqLeftBytes.length)throw new E("invalid signature: left bytes after parsing");const{v:rBytes,l:rLeftBytes}=tlv.decode(2,seqBytes);const{v:sBytes,l:sLeftBytes}=tlv.decode(2,rLeftBytes);if(sLeftBytes.length)throw new E("invalid signature: left bytes after parsing");return{r:int.decode(rBytes),s:int.decode(sBytes)}},hexFromSig(sig){const{_tlv:tlv,_int:int}=DER;const rs=tlv.encode(2,int.encode(sig.r));const ss=tlv.encode(2,int.encode(sig.s));const seq=rs+ss;return tlv.encode(48,seq)}};const _0n=BigInt(0),_1n$1=BigInt(1);BigInt(2);const _3n=BigInt(3);BigInt(4);function weierstrassPoints(opts){const CURVE=validatePointOpts(opts);const{Fp:Fp}=CURVE;const Fn=Field(CURVE.n,CURVE.nBitLength);const toBytes=CURVE.toBytes||((_c,point,_isCompressed)=>{const a=point.toAffine();return concatBytes(Uint8Array.from([4]),Fp.toBytes(a.x),Fp.toBytes(a.y))});const fromBytes=CURVE.fromBytes||(bytes=>{const tail=bytes.subarray(1);const x=Fp.fromBytes(tail.subarray(0,Fp.BYTES));const y=Fp.fromBytes(tail.subarray(Fp.BYTES,2*Fp.BYTES));return{x:x,y:y}});function weierstrassEquation(x){const{a:a,b:b}=CURVE;const x2=Fp.sqr(x);const x3=Fp.mul(x2,x);return Fp.add(Fp.add(x3,Fp.mul(x,a)),b)}if(!Fp.eql(Fp.sqr(CURVE.Gy),weierstrassEquation(CURVE.Gx)))throw new Error("bad generator point: equation left != right");function isWithinCurveOrder(num){return inRange(num,_1n$1,CURVE.n)}function normPrivateKeyToScalar(key){const{allowedPrivateKeyLengths:lengths,nByteLength:nByteLength,wrapPrivateKey:wrapPrivateKey,n:N}=CURVE;if(lengths&&typeof key!=="bigint"){if(isBytes$1(key))key=bytesToHex(key);if(typeof key!=="string"||!lengths.includes(key.length))throw new Error("invalid private key");key=key.padStart(nByteLength*2,"0")}let num;try{num=typeof key==="bigint"?key:bytesToNumberBE(ensureBytes("private key",key,nByteLength))}catch(error){throw new Error("invalid private key, expected hex or "+nByteLength+" bytes, got "+typeof key)}if(wrapPrivateKey)num=mod(num,N);aInRange("private key",num,_1n$1,N);return num}function assertPrjPoint(other){if(!(other instanceof Point))throw new Error("ProjectivePoint expected")}const toAffineMemo=memoized(((p,iz)=>{const{px:x,py:y,pz:z}=p;if(Fp.eql(z,Fp.ONE))return{x:x,y:y};const is0=p.is0();if(iz==null)iz=is0?Fp.ONE:Fp.inv(z);const ax=Fp.mul(x,iz);const ay=Fp.mul(y,iz);const zz=Fp.mul(z,iz);if(is0)return{x:Fp.ZERO,y:Fp.ZERO};if(!Fp.eql(zz,Fp.ONE))throw new Error("invZ was invalid");return{x:ax,y:ay}}));const assertValidMemo=memoized((p=>{if(p.is0()){if(CURVE.allowInfinityPoint&&!Fp.is0(p.py))return;throw new Error("bad point: ZERO")}const{x:x,y:y}=p.toAffine();if(!Fp.isValid(x)||!Fp.isValid(y))throw new Error("bad point: x or y not FE");const left=Fp.sqr(y);const right=weierstrassEquation(x);if(!Fp.eql(left,right))throw new Error("bad point: equation left != right");if(!p.isTorsionFree())throw new Error("bad point: not in prime-order subgroup");return true}));class Point{constructor(px,py,pz){this.px=px;this.py=py;this.pz=pz;if(px==null||!Fp.isValid(px))throw new Error("x required");if(py==null||!Fp.isValid(py))throw new Error("y required");if(pz==null||!Fp.isValid(pz))throw new Error("z required");Object.freeze(this)}static fromAffine(p){const{x:x,y:y}=p||{};if(!p||!Fp.isValid(x)||!Fp.isValid(y))throw new Error("invalid affine point");if(p instanceof Point)throw new Error("projective point not allowed");const is0=i=>Fp.eql(i,Fp.ZERO);if(is0(x)&&is0(y))return Point.ZERO;return new Point(x,y,Fp.ONE)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}static normalizeZ(points){const toInv=Fp.invertBatch(points.map((p=>p.pz)));return points.map(((p,i)=>p.toAffine(toInv[i]))).map(Point.fromAffine)}static fromHex(hex){const P=Point.fromAffine(fromBytes(ensureBytes("pointHex",hex)));P.assertValidity();return P}static fromPrivateKey(privateKey){return Point.BASE.multiply(normPrivateKeyToScalar(privateKey))}static msm(points,scalars){return pippenger(Point,Fn,points,scalars)}_setWindowSize(windowSize){wnaf.setWindowSize(this,windowSize)}assertValidity(){assertValidMemo(this)}hasEvenY(){const{y:y}=this.toAffine();if(Fp.isOdd)return!Fp.isOdd(y);throw new Error("Field doesn't support isOdd")}equals(other){assertPrjPoint(other);const{px:X1,py:Y1,pz:Z1}=this;const{px:X2,py:Y2,pz:Z2}=other;const U1=Fp.eql(Fp.mul(X1,Z2),Fp.mul(X2,Z1));const U2=Fp.eql(Fp.mul(Y1,Z2),Fp.mul(Y2,Z1));return U1&&U2}negate(){return new Point(this.px,Fp.neg(this.py),this.pz)}double(){const{a:a,b:b}=CURVE;const b3=Fp.mul(b,_3n);const{px:X1,py:Y1,pz:Z1}=this;let X3=Fp.ZERO,Y3=Fp.ZERO,Z3=Fp.ZERO;let t0=Fp.mul(X1,X1);let t1=Fp.mul(Y1,Y1);let t2=Fp.mul(Z1,Z1);let t3=Fp.mul(X1,Y1);t3=Fp.add(t3,t3);Z3=Fp.mul(X1,Z1);Z3=Fp.add(Z3,Z3);X3=Fp.mul(a,Z3);Y3=Fp.mul(b3,t2);Y3=Fp.add(X3,Y3);X3=Fp.sub(t1,Y3);Y3=Fp.add(t1,Y3);Y3=Fp.mul(X3,Y3);X3=Fp.mul(t3,X3);Z3=Fp.mul(b3,Z3);t2=Fp.mul(a,t2);t3=Fp.sub(t0,t2);t3=Fp.mul(a,t3);t3=Fp.add(t3,Z3);Z3=Fp.add(t0,t0);t0=Fp.add(Z3,t0);t0=Fp.add(t0,t2);t0=Fp.mul(t0,t3);Y3=Fp.add(Y3,t0);t2=Fp.mul(Y1,Z1);t2=Fp.add(t2,t2);t0=Fp.mul(t2,t3);X3=Fp.sub(X3,t0);Z3=Fp.mul(t2,t1);Z3=Fp.add(Z3,Z3);Z3=Fp.add(Z3,Z3);return new Point(X3,Y3,Z3)}add(other){assertPrjPoint(other);const{px:X1,py:Y1,pz:Z1}=this;const{px:X2,py:Y2,pz:Z2}=other;let X3=Fp.ZERO,Y3=Fp.ZERO,Z3=Fp.ZERO;const a=CURVE.a;const b3=Fp.mul(CURVE.b,_3n);let t0=Fp.mul(X1,X2);let t1=Fp.mul(Y1,Y2);let t2=Fp.mul(Z1,Z2);let t3=Fp.add(X1,Y1);let t4=Fp.add(X2,Y2);t3=Fp.mul(t3,t4);t4=Fp.add(t0,t1);t3=Fp.sub(t3,t4);t4=Fp.add(X1,Z1);let t5=Fp.add(X2,Z2);t4=Fp.mul(t4,t5);t5=Fp.add(t0,t2);t4=Fp.sub(t4,t5);t5=Fp.add(Y1,Z1);X3=Fp.add(Y2,Z2);t5=Fp.mul(t5,X3);X3=Fp.add(t1,t2);t5=Fp.sub(t5,X3);Z3=Fp.mul(a,t4);X3=Fp.mul(b3,t2);Z3=Fp.add(X3,Z3);X3=Fp.sub(t1,Z3);Z3=Fp.add(t1,Z3);Y3=Fp.mul(X3,Z3);t1=Fp.add(t0,t0);t1=Fp.add(t1,t0);t2=Fp.mul(a,t2);t4=Fp.mul(b3,t4);t1=Fp.add(t1,t2);t2=Fp.sub(t0,t2);t2=Fp.mul(a,t2);t4=Fp.add(t4,t2);t0=Fp.mul(t1,t4);Y3=Fp.add(Y3,t0);t0=Fp.mul(t5,t4);X3=Fp.mul(t3,X3);X3=Fp.sub(X3,t0);t0=Fp.mul(t3,t1);Z3=Fp.mul(t5,Z3);Z3=Fp.add(Z3,t0);return new Point(X3,Y3,Z3)}subtract(other){return this.add(other.negate())}is0(){return this.equals(Point.ZERO)}wNAF(n){return wnaf.wNAFCached(this,n,Point.normalizeZ)}multiplyUnsafe(sc){const{endo:endo,n:N}=CURVE;aInRange("scalar",sc,_0n,N);const I=Point.ZERO;if(sc===_0n)return I;if(this.is0()||sc===_1n$1)return this;if(!endo||wnaf.hasPrecomputes(this))return wnaf.wNAFCachedUnsafe(this,sc,Point.normalizeZ);let{k1neg:k1neg,k1:k1,k2neg:k2neg,k2:k2}=endo.splitScalar(sc);let k1p=I;let k2p=I;let d=this;while(k1>_0n||k2>_0n){if(k1&_1n$1)k1p=k1p.add(d);if(k2&_1n$1)k2p=k2p.add(d);d=d.double();k1>>=_1n$1;k2>>=_1n$1}if(k1neg)k1p=k1p.negate();if(k2neg)k2p=k2p.negate();k2p=new Point(Fp.mul(k2p.px,endo.beta),k2p.py,k2p.pz);return k1p.add(k2p)}multiply(scalar){const{endo:endo,n:N}=CURVE;aInRange("scalar",scalar,_1n$1,N);let point,fake;if(endo){const{k1neg:k1neg,k1:k1,k2neg:k2neg,k2:k2}=endo.splitScalar(scalar);let{p:k1p,f:f1p}=this.wNAF(k1);let{p:k2p,f:f2p}=this.wNAF(k2);k1p=wnaf.constTimeNegate(k1neg,k1p);k2p=wnaf.constTimeNegate(k2neg,k2p);k2p=new Point(Fp.mul(k2p.px,endo.beta),k2p.py,k2p.pz);point=k1p.add(k2p);fake=f1p.add(f2p)}else{const{p:p,f:f}=this.wNAF(scalar);point=p;fake=f}return Point.normalizeZ([point,fake])[0]}multiplyAndAddUnsafe(Q,a,b){const G=Point.BASE;const mul=(P,a)=>a===_0n||a===_1n$1||!P.equals(G)?P.multiplyUnsafe(a):P.multiply(a);const sum=mul(this,a).add(mul(Q,b));return sum.is0()?undefined:sum}toAffine(iz){return toAffineMemo(this,iz)}isTorsionFree(){const{h:cofactor,isTorsionFree:isTorsionFree}=CURVE;if(cofactor===_1n$1)return true;if(isTorsionFree)return isTorsionFree(Point,this);throw new Error("isTorsionFree() has not been declared for the elliptic curve")}clearCofactor(){const{h:cofactor,clearCofactor:clearCofactor}=CURVE;if(cofactor===_1n$1)return this;if(clearCofactor)return clearCofactor(Point,this);return this.multiplyUnsafe(CURVE.h)}toRawBytes(isCompressed=true){abool("isCompressed",isCompressed);this.assertValidity();return toBytes(Point,this,isCompressed)}toHex(isCompressed=true){abool("isCompressed",isCompressed);return bytesToHex(this.toRawBytes(isCompressed))}}Point.BASE=new Point(CURVE.Gx,CURVE.Gy,Fp.ONE);Point.ZERO=new Point(Fp.ZERO,Fp.ONE,Fp.ZERO);const _bits=CURVE.nBitLength;const wnaf=wNAF(Point,CURVE.endo?Math.ceil(_bits/2):_bits);return{CURVE:CURVE,ProjectivePoint:Point,normPrivateKeyToScalar:normPrivateKeyToScalar,weierstrassEquation:weierstrassEquation,isWithinCurveOrder:isWithinCurveOrder}}function validateOpts(curve){const opts=validateBasic(curve);validateObject(opts,{hash:"hash",hmac:"function",randomBytes:"function"},{bits2int:"function",bits2int_modN:"function",lowS:"boolean"});return Object.freeze({lowS:true,...opts})}function weierstrass(curveDef){const CURVE=validateOpts(curveDef);const{Fp:Fp,n:CURVE_ORDER}=CURVE;const compressedLen=Fp.BYTES+1;const uncompressedLen=2*Fp.BYTES+1;function modN(a){return mod(a,CURVE_ORDER)}function invN(a){return invert(a,CURVE_ORDER)}const{ProjectivePoint:Point,normPrivateKeyToScalar:normPrivateKeyToScalar,weierstrassEquation:weierstrassEquation,isWithinCurveOrder:isWithinCurveOrder}=weierstrassPoints({...CURVE,toBytes(_c,point,isCompressed){const a=point.toAffine();const x=Fp.toBytes(a.x);const cat=concatBytes;abool("isCompressed",isCompressed);if(isCompressed){return cat(Uint8Array.from([point.hasEvenY()?2:3]),x)}else{return cat(Uint8Array.from([4]),x,Fp.toBytes(a.y))}},fromBytes(bytes){const len=bytes.length;const head=bytes[0];const tail=bytes.subarray(1);if(len===compressedLen&&(head===2||head===3)){const x=bytesToNumberBE(tail);if(!inRange(x,_1n$1,Fp.ORDER))throw new Error("Point is not on curve");const y2=weierstrassEquation(x);let y;try{y=Fp.sqrt(y2)}catch(sqrtError){const suffix=sqrtError instanceof Error?": "+sqrtError.message:"";throw new Error("Point is not on curve"+suffix)}const isYOdd=(y&_1n$1)===_1n$1;const isHeadOdd=(head&1)===1;if(isHeadOdd!==isYOdd)y=Fp.neg(y);return{x:x,y:y}}else if(len===uncompressedLen&&head===4){const x=Fp.fromBytes(tail.subarray(0,Fp.BYTES));const y=Fp.fromBytes(tail.subarray(Fp.BYTES,2*Fp.BYTES));return{x:x,y:y}}else{const cl=compressedLen;const ul=uncompressedLen;throw new Error("invalid Point, expected length of "+cl+", or uncompressed "+ul+", got "+len)}}});const numToNByteStr=num=>bytesToHex(numberToBytesBE(num,CURVE.nByteLength));function isBiggerThanHalfOrder(number){const HALF=CURVE_ORDER>>_1n$1;return number>HALF}function normalizeS(s){return isBiggerThanHalfOrder(s)?modN(-s):s}const slcNum=(b,from,to)=>bytesToNumberBE(b.slice(from,to));class Signature{constructor(r,s,recovery){this.r=r;this.s=s;this.recovery=recovery;this.assertValidity()}static fromCompact(hex){const l=CURVE.nByteLength;hex=ensureBytes("compactSignature",hex,l*2);return new Signature(slcNum(hex,0,l),slcNum(hex,l,2*l))}static fromDER(hex){const{r:r,s:s}=DER.toSig(ensureBytes("DER",hex));return new Signature(r,s)}assertValidity(){aInRange("r",this.r,_1n$1,CURVE_ORDER);aInRange("s",this.s,_1n$1,CURVE_ORDER)}addRecoveryBit(recovery){return new Signature(this.r,this.s,recovery)}recoverPublicKey(msgHash){const{r:r,s:s,recovery:rec}=this;const h=bits2int_modN(ensureBytes("msgHash",msgHash));if(rec==null||![0,1,2,3].includes(rec))throw new Error("recovery id invalid");const radj=rec===2||rec===3?r+CURVE.n:r;if(radj>=Fp.ORDER)throw new Error("recovery id 2 or 3 invalid");const prefix=(rec&1)===0?"02":"03";const R=Point.fromHex(prefix+numToNByteStr(radj));const ir=invN(radj);const u1=modN(-h*ir);const u2=modN(s*ir);const Q=Point.BASE.multiplyAndAddUnsafe(R,u1,u2);if(!Q)throw new Error("point at infinify");Q.assertValidity();return Q}hasHighS(){return isBiggerThanHalfOrder(this.s)}normalizeS(){return this.hasHighS()?new Signature(this.r,modN(-this.s),this.recovery):this}toDERRawBytes(){return hexToBytes(this.toDERHex())}toDERHex(){return DER.hexFromSig({r:this.r,s:this.s})}toCompactRawBytes(){return hexToBytes(this.toCompactHex())}toCompactHex(){return numToNByteStr(this.r)+numToNByteStr(this.s)}}const utils={isValidPrivateKey(privateKey){try{normPrivateKeyToScalar(privateKey);return true}catch(error){return false}},normPrivateKeyToScalar:normPrivateKeyToScalar,randomPrivateKey:()=>{const length=getMinHashLength(CURVE.n);return mapHashToField(CURVE.randomBytes(length),CURVE.n)},precompute(windowSize=8,point=Point.BASE){point._setWindowSize(windowSize);point.multiply(BigInt(3));return point}};function getPublicKey(privateKey,isCompressed=true){return Point.fromPrivateKey(privateKey).toRawBytes(isCompressed)}function isProbPub(item){const arr=isBytes$1(item);const str=typeof item==="string";const len=(arr||str)&&item.length;if(arr)return len===compressedLen||len===uncompressedLen;if(str)return len===2*compressedLen||len===2*uncompressedLen;if(item instanceof Point)return true;return false}function getSharedSecret(privateA,publicB,isCompressed=true){if(isProbPub(privateA))throw new Error("first arg must be private key");if(!isProbPub(publicB))throw new Error("second arg must be public key");const b=Point.fromHex(publicB);return b.multiply(normPrivateKeyToScalar(privateA)).toRawBytes(isCompressed)}const bits2int=CURVE.bits2int||function(bytes){if(bytes.length>8192)throw new Error("input is too large");const num=bytesToNumberBE(bytes);const delta=bytes.length*8-CURVE.nBitLength;return delta>0?num>>BigInt(delta):num};const bits2int_modN=CURVE.bits2int_modN||function(bytes){return modN(bits2int(bytes))};const ORDER_MASK=bitMask(CURVE.nBitLength);function int2octets(num){aInRange("num < 2^"+CURVE.nBitLength,num,_0n,ORDER_MASK);return numberToBytesBE(num,CURVE.nByteLength)}function prepSig(msgHash,privateKey,opts=defaultSigOpts){if(["recovered","canonical"].some((k=>k in opts)))throw new Error("sign() legacy options not supported");const{hash:hash,randomBytes:randomBytes}=CURVE;let{lowS:lowS,prehash:prehash,extraEntropy:ent}=opts;if(lowS==null)lowS=true;msgHash=ensureBytes("msgHash",msgHash);validateSigVerOpts(opts);if(prehash)msgHash=ensureBytes("prehashed msgHash",hash(msgHash));const h1int=bits2int_modN(msgHash);const d=normPrivateKeyToScalar(privateKey);const seedArgs=[int2octets(d),int2octets(h1int)];if(ent!=null&&ent!==false){const e=ent===true?randomBytes(Fp.BYTES):ent;seedArgs.push(ensureBytes("extraEntropy",e))}const seed=concatBytes(...seedArgs);const m=h1int;function k2sig(kBytes){const k=bits2int(kBytes);if(!isWithinCurveOrder(k))return;const ik=invN(k);const q=Point.BASE.multiply(k).toAffine();const r=modN(q.x);if(r===_0n)return;const s=modN(ik*modN(m+r*d));if(s===_0n)return;let recovery=(q.x===r?0:2)|Number(q.y&_1n$1);let normS=s;if(lowS&&isBiggerThanHalfOrder(s)){normS=normalizeS(s);recovery^=1}return new Signature(r,normS,recovery)}return{seed:seed,k2sig:k2sig}}const defaultSigOpts={lowS:CURVE.lowS,prehash:false};const defaultVerOpts={lowS:CURVE.lowS,prehash:false};function sign(msgHash,privKey,opts=defaultSigOpts){const{seed:seed,k2sig:k2sig}=prepSig(msgHash,privKey,opts);const C=CURVE;const drbg=createHmacDrbg(C.hash.outputLen,C.nByteLength,C.hmac);return drbg(seed,k2sig)}Point.BASE._setWindowSize(8);function verify(signature,msgHash,publicKey,opts=defaultVerOpts){const sg=signature;msgHash=ensureBytes("msgHash",msgHash);publicKey=ensureBytes("publicKey",publicKey);const{lowS:lowS,prehash:prehash,format:format}=opts;validateSigVerOpts(opts);if("strict"in opts)throw new Error("options.strict was renamed to lowS");if(format!==undefined&&format!=="compact"&&format!=="der")throw new Error("format must be compact or der");const isHex=typeof sg==="string"||isBytes$1(sg);const isObj=!isHex&&!format&&typeof sg==="object"&&sg!==null&&typeof sg.r==="bigint"&&typeof sg.s==="bigint";if(!isHex&&!isObj)throw new Error("invalid signature, expected Uint8Array, hex string or Signature instance");let _sig=undefined;let P;try{if(isObj)_sig=new Signature(sg.r,sg.s);if(isHex){try{if(format!=="compact")_sig=Signature.fromDER(sg)}catch(derError){if(!(derError instanceof DER.Err))throw derError}if(!_sig&&format!=="der")_sig=Signature.fromCompact(sg)}P=Point.fromHex(publicKey)}catch(error){return false}if(!_sig)return false;if(lowS&&_sig.hasHighS())return false;if(prehash)msgHash=CURVE.hash(msgHash);const{r:r,s:s}=_sig;const h=bits2int_modN(msgHash);const is=invN(s);const u1=modN(h*is);const u2=modN(r*is);const R=Point.BASE.multiplyAndAddUnsafe(P,u1,u2)?.toAffine();if(!R)return false;const v=modN(R.x);return v===r}return{CURVE:CURVE,getPublicKey:getPublicKey,getSharedSecret:getSharedSecret,sign:sign,verify:verify,ProjectivePoint:Point,Signature:Signature,utils:utils}} +/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */function getHash(hash){return{hash:hash,hmac:(key,...msgs)=>hmac(hash,key,concatBytes$1(...msgs)),randomBytes:randomBytes}}function createCurve(curveDef,defHash){const create=hash=>weierstrass({...curveDef,...getHash(hash)});return Object.freeze({...create(defHash),create:create})} +/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */const secp256k1P=BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f");const secp256k1N=BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141");const _1n=BigInt(1);const _2n=BigInt(2);const divNearest=(a,b)=>(a+b/_2n)/b;function sqrtMod(y){const P=secp256k1P;const _3n=BigInt(3),_6n=BigInt(6),_11n=BigInt(11),_22n=BigInt(22);const _23n=BigInt(23),_44n=BigInt(44),_88n=BigInt(88);const b2=y*y*y%P;const b3=b2*b2*y%P;const b6=pow2(b3,_3n,P)*b3%P;const b9=pow2(b6,_3n,P)*b3%P;const b11=pow2(b9,_2n,P)*b2%P;const b22=pow2(b11,_11n,P)*b11%P;const b44=pow2(b22,_22n,P)*b22%P;const b88=pow2(b44,_44n,P)*b44%P;const b176=pow2(b88,_88n,P)*b88%P;const b220=pow2(b176,_44n,P)*b44%P;const b223=pow2(b220,_3n,P)*b3%P;const t1=pow2(b223,_23n,P)*b22%P;const t2=pow2(t1,_6n,P)*b2%P;const root=pow2(t2,_2n,P);if(!Fpk1.eql(Fpk1.sqr(root),y))throw new Error("Cannot find square root");return root}const Fpk1=Field(secp256k1P,undefined,undefined,{sqrt:sqrtMod});const secp256k1=createCurve({a:BigInt(0),b:BigInt(7),Fp:Fpk1,n:secp256k1N,Gx:BigInt("55066263022277343669578718895168534326250603453777594175500187360389116729240"),Gy:BigInt("32670510020758816978083085130507043184471273380659243275938904335757337482424"),h:BigInt(1),lowS:true,endo:{beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),splitScalar:k=>{const n=secp256k1N;const a1=BigInt("0x3086d221a7d46bcde86c90e49284eb15");const b1=-_1n*BigInt("0xe4437ed6010e88286f547fa90abfe4c3");const a2=BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8");const b2=a1;const POW_2_128=BigInt("0x100000000000000000000000000000000");const c1=divNearest(b2*k,n);const c2=divNearest(-b1*k,n);let k1=mod(k-c1*a1-c2*a2,n);let k2=mod(-c1*b1-c2*b2,n);const k1neg=k1>POW_2_128;const k2neg=k2>POW_2_128;if(k1neg)k1=n-k1;if(k2neg)k2=n-k2;if(k1>POW_2_128||k2>POW_2_128){throw new Error("splitScalar: Endomorphism failed, k="+k)}return{k1neg:k1neg,k1:k1,k2neg:k2neg,k2:k2}}}},sha256);BigInt(0);secp256k1.ProjectivePoint;const ecdsaSign=(msgHash,privKey)=>{const signature=secp256k1.sign(msgHash,privKey);return[signature.toCompactRawBytes(),signature.recovery]};secp256k1.utils.isValidPrivateKey;const publicKeyCreate=secp256k1.getPublicKey;const PRIVATE_KEY_BYTES=32;const ETHEREUM_ADDRESS_BYTES=20;const PUBLIC_KEY_BYTES=64;const SIGNATURE_OFFSETS_SERIALIZED_SIZE=11;const SECP256K1_INSTRUCTION_LAYOUT=LayoutExports.struct([LayoutExports.u8("numSignatures"),LayoutExports.u16("signatureOffset"),LayoutExports.u8("signatureInstructionIndex"),LayoutExports.u16("ethAddressOffset"),LayoutExports.u8("ethAddressInstructionIndex"),LayoutExports.u16("messageDataOffset"),LayoutExports.u16("messageDataSize"),LayoutExports.u8("messageInstructionIndex"),LayoutExports.blob(20,"ethAddress"),LayoutExports.blob(64,"signature"),LayoutExports.u8("recoveryId")]);class Secp256k1Program{constructor(){}static publicKeyToEthAddress(publicKey){assert$1(publicKey.length===PUBLIC_KEY_BYTES,`Public key must be ${PUBLIC_KEY_BYTES} bytes but received ${publicKey.length} bytes`);try{return bufferExports.Buffer.from(keccak_256(toBuffer(publicKey))).slice(-ETHEREUM_ADDRESS_BYTES)}catch(error){throw new Error(`Error constructing Ethereum address: ${error}`)}}static createInstructionWithPublicKey(params){const{publicKey:publicKey,message:message,signature:signature,recoveryId:recoveryId,instructionIndex:instructionIndex}=params;return Secp256k1Program.createInstructionWithEthAddress({ethAddress:Secp256k1Program.publicKeyToEthAddress(publicKey),message:message,signature:signature,recoveryId:recoveryId,instructionIndex:instructionIndex})}static createInstructionWithEthAddress(params){const{ethAddress:rawAddress,message:message,signature:signature,recoveryId:recoveryId,instructionIndex:instructionIndex=0}=params;let ethAddress;if(typeof rawAddress==="string"){if(rawAddress.startsWith("0x")){ethAddress=bufferExports.Buffer.from(rawAddress.substr(2),"hex")}else{ethAddress=bufferExports.Buffer.from(rawAddress,"hex")}}else{ethAddress=rawAddress}assert$1(ethAddress.length===ETHEREUM_ADDRESS_BYTES,`Address must be ${ETHEREUM_ADDRESS_BYTES} bytes but received ${ethAddress.length} bytes`);const dataStart=1+SIGNATURE_OFFSETS_SERIALIZED_SIZE;const ethAddressOffset=dataStart;const signatureOffset=dataStart+ethAddress.length;const messageDataOffset=signatureOffset+signature.length+1;const numSignatures=1;const instructionData=bufferExports.Buffer.alloc(SECP256K1_INSTRUCTION_LAYOUT.span+message.length);SECP256K1_INSTRUCTION_LAYOUT.encode({numSignatures:numSignatures,signatureOffset:signatureOffset,signatureInstructionIndex:instructionIndex,ethAddressOffset:ethAddressOffset,ethAddressInstructionIndex:instructionIndex,messageDataOffset:messageDataOffset,messageDataSize:message.length,messageInstructionIndex:instructionIndex,signature:toBuffer(signature),ethAddress:toBuffer(ethAddress),recoveryId:recoveryId},instructionData);instructionData.fill(toBuffer(message),SECP256K1_INSTRUCTION_LAYOUT.span);return new TransactionInstruction({keys:[],programId:Secp256k1Program.programId,data:instructionData})}static createInstructionWithPrivateKey(params){const{privateKey:pkey,message:message,instructionIndex:instructionIndex}=params;assert$1(pkey.length===PRIVATE_KEY_BYTES,`Private key must be ${PRIVATE_KEY_BYTES} bytes but received ${pkey.length} bytes`);try{const privateKey=toBuffer(pkey);const publicKey=publicKeyCreate(privateKey,false).slice(1);const messageHash=bufferExports.Buffer.from(keccak_256(toBuffer(message)));const[signature,recoveryId]=ecdsaSign(messageHash,privateKey);return this.createInstructionWithPublicKey({publicKey:publicKey,message:message,signature:signature,recoveryId:recoveryId,instructionIndex:instructionIndex})}catch(error){throw new Error(`Error creating instruction; ${error}`)}}}Secp256k1Program.programId=new PublicKey("KeccakSecp256k11111111111111111111111111111");var _Lockup;const STAKE_CONFIG_ID=new PublicKey("StakeConfig11111111111111111111111111111111");class Authorized{constructor(staker,withdrawer){this.staker=void 0;this.withdrawer=void 0;this.staker=staker;this.withdrawer=withdrawer}}class Lockup{constructor(unixTimestamp,epoch,custodian){this.unixTimestamp=void 0;this.epoch=void 0;this.custodian=void 0;this.unixTimestamp=unixTimestamp;this.epoch=epoch;this.custodian=custodian}}_Lockup=Lockup;Lockup.default=new _Lockup(0,0,PublicKey.default);class StakeInstruction{constructor(){}static decodeInstructionType(instruction){this.checkProgramId(instruction.programId);const instructionTypeLayout=LayoutExports.u32("instruction");const typeIndex=instructionTypeLayout.decode(instruction.data);let type;for(const[ixType,layout]of Object.entries(STAKE_INSTRUCTION_LAYOUTS)){if(layout.index==typeIndex){type=ixType;break}}if(!type){throw new Error("Instruction type incorrect; not a StakeInstruction")}return type}static decodeInitialize(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,2);const{authorized:authorized,lockup:lockup}=decodeData$1(STAKE_INSTRUCTION_LAYOUTS.Initialize,instruction.data);return{stakePubkey:instruction.keys[0].pubkey,authorized:new Authorized(new PublicKey(authorized.staker),new PublicKey(authorized.withdrawer)),lockup:new Lockup(lockup.unixTimestamp,lockup.epoch,new PublicKey(lockup.custodian))}}static decodeDelegate(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,6);decodeData$1(STAKE_INSTRUCTION_LAYOUTS.Delegate,instruction.data);return{stakePubkey:instruction.keys[0].pubkey,votePubkey:instruction.keys[1].pubkey,authorizedPubkey:instruction.keys[5].pubkey}}static decodeAuthorize(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);const{newAuthorized:newAuthorized,stakeAuthorizationType:stakeAuthorizationType}=decodeData$1(STAKE_INSTRUCTION_LAYOUTS.Authorize,instruction.data);const o={stakePubkey:instruction.keys[0].pubkey,authorizedPubkey:instruction.keys[2].pubkey,newAuthorizedPubkey:new PublicKey(newAuthorized),stakeAuthorizationType:{index:stakeAuthorizationType}};if(instruction.keys.length>3){o.custodianPubkey=instruction.keys[3].pubkey}return o}static decodeAuthorizeWithSeed(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,2);const{newAuthorized:newAuthorized,stakeAuthorizationType:stakeAuthorizationType,authoritySeed:authoritySeed,authorityOwner:authorityOwner}=decodeData$1(STAKE_INSTRUCTION_LAYOUTS.AuthorizeWithSeed,instruction.data);const o={stakePubkey:instruction.keys[0].pubkey,authorityBase:instruction.keys[1].pubkey,authoritySeed:authoritySeed,authorityOwner:new PublicKey(authorityOwner),newAuthorizedPubkey:new PublicKey(newAuthorized),stakeAuthorizationType:{index:stakeAuthorizationType}};if(instruction.keys.length>3){o.custodianPubkey=instruction.keys[3].pubkey}return o}static decodeSplit(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);const{lamports:lamports}=decodeData$1(STAKE_INSTRUCTION_LAYOUTS.Split,instruction.data);return{stakePubkey:instruction.keys[0].pubkey,splitStakePubkey:instruction.keys[1].pubkey,authorizedPubkey:instruction.keys[2].pubkey,lamports:lamports}}static decodeMerge(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);decodeData$1(STAKE_INSTRUCTION_LAYOUTS.Merge,instruction.data);return{stakePubkey:instruction.keys[0].pubkey,sourceStakePubKey:instruction.keys[1].pubkey,authorizedPubkey:instruction.keys[4].pubkey}}static decodeWithdraw(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,5);const{lamports:lamports}=decodeData$1(STAKE_INSTRUCTION_LAYOUTS.Withdraw,instruction.data);const o={stakePubkey:instruction.keys[0].pubkey,toPubkey:instruction.keys[1].pubkey,authorizedPubkey:instruction.keys[4].pubkey,lamports:lamports};if(instruction.keys.length>5){o.custodianPubkey=instruction.keys[5].pubkey}return o}static decodeDeactivate(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);decodeData$1(STAKE_INSTRUCTION_LAYOUTS.Deactivate,instruction.data);return{stakePubkey:instruction.keys[0].pubkey,authorizedPubkey:instruction.keys[2].pubkey}}static checkProgramId(programId){if(!programId.equals(StakeProgram.programId)){throw new Error("invalid instruction; programId is not StakeProgram")}}static checkKeyLength(keys,expectedLength){if(keys.length0){transaction.add(SystemProgram.transfer({fromPubkey:params.authorizedPubkey,toPubkey:splitStakePubkey,lamports:rentExemptReserve}))}return transaction.add(this.splitInstruction({stakePubkey:stakePubkey,authorizedPubkey:authorizedPubkey,splitStakePubkey:splitStakePubkey,lamports:lamports}))}static merge(params){const{stakePubkey:stakePubkey,sourceStakePubKey:sourceStakePubKey,authorizedPubkey:authorizedPubkey}=params;const type=STAKE_INSTRUCTION_LAYOUTS.Merge;const data=encodeData(type);return(new Transaction).add({keys:[{pubkey:stakePubkey,isSigner:false,isWritable:true},{pubkey:sourceStakePubKey,isSigner:false,isWritable:true},{pubkey:SYSVAR_CLOCK_PUBKEY,isSigner:false,isWritable:false},{pubkey:SYSVAR_STAKE_HISTORY_PUBKEY,isSigner:false,isWritable:false},{pubkey:authorizedPubkey,isSigner:true,isWritable:false}],programId:this.programId,data:data})}static withdraw(params){const{stakePubkey:stakePubkey,authorizedPubkey:authorizedPubkey,toPubkey:toPubkey,lamports:lamports,custodianPubkey:custodianPubkey}=params;const type=STAKE_INSTRUCTION_LAYOUTS.Withdraw;const data=encodeData(type,{lamports:lamports});const keys=[{pubkey:stakePubkey,isSigner:false,isWritable:true},{pubkey:toPubkey,isSigner:false,isWritable:true},{pubkey:SYSVAR_CLOCK_PUBKEY,isSigner:false,isWritable:false},{pubkey:SYSVAR_STAKE_HISTORY_PUBKEY,isSigner:false,isWritable:false},{pubkey:authorizedPubkey,isSigner:true,isWritable:false}];if(custodianPubkey){keys.push({pubkey:custodianPubkey,isSigner:true,isWritable:false})}return(new Transaction).add({keys:keys,programId:this.programId,data:data})}static deactivate(params){const{stakePubkey:stakePubkey,authorizedPubkey:authorizedPubkey}=params;const type=STAKE_INSTRUCTION_LAYOUTS.Deactivate;const data=encodeData(type);return(new Transaction).add({keys:[{pubkey:stakePubkey,isSigner:false,isWritable:true},{pubkey:SYSVAR_CLOCK_PUBKEY,isSigner:false,isWritable:false},{pubkey:authorizedPubkey,isSigner:true,isWritable:false}],programId:this.programId,data:data})}}StakeProgram.programId=new PublicKey("Stake11111111111111111111111111111111111111");StakeProgram.space=200;class VoteInit{constructor(nodePubkey,authorizedVoter,authorizedWithdrawer,commission){this.nodePubkey=void 0;this.authorizedVoter=void 0;this.authorizedWithdrawer=void 0;this.commission=void 0;this.nodePubkey=nodePubkey;this.authorizedVoter=authorizedVoter;this.authorizedWithdrawer=authorizedWithdrawer;this.commission=commission}}class VoteInstruction{constructor(){}static decodeInstructionType(instruction){this.checkProgramId(instruction.programId);const instructionTypeLayout=LayoutExports.u32("instruction");const typeIndex=instructionTypeLayout.decode(instruction.data);let type;for(const[ixType,layout]of Object.entries(VOTE_INSTRUCTION_LAYOUTS)){if(layout.index==typeIndex){type=ixType;break}}if(!type){throw new Error("Instruction type incorrect; not a VoteInstruction")}return type}static decodeInitializeAccount(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,4);const{voteInit:voteInit}=decodeData$1(VOTE_INSTRUCTION_LAYOUTS.InitializeAccount,instruction.data);return{votePubkey:instruction.keys[0].pubkey,nodePubkey:instruction.keys[3].pubkey,voteInit:new VoteInit(new PublicKey(voteInit.nodePubkey),new PublicKey(voteInit.authorizedVoter),new PublicKey(voteInit.authorizedWithdrawer),voteInit.commission)}}static decodeAuthorize(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);const{newAuthorized:newAuthorized,voteAuthorizationType:voteAuthorizationType}=decodeData$1(VOTE_INSTRUCTION_LAYOUTS.Authorize,instruction.data);return{votePubkey:instruction.keys[0].pubkey,authorizedPubkey:instruction.keys[2].pubkey,newAuthorizedPubkey:new PublicKey(newAuthorized),voteAuthorizationType:{index:voteAuthorizationType}}}static decodeAuthorizeWithSeed(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);const{voteAuthorizeWithSeedArgs:{currentAuthorityDerivedKeyOwnerPubkey:currentAuthorityDerivedKeyOwnerPubkey,currentAuthorityDerivedKeySeed:currentAuthorityDerivedKeySeed,newAuthorized:newAuthorized,voteAuthorizationType:voteAuthorizationType}}=decodeData$1(VOTE_INSTRUCTION_LAYOUTS.AuthorizeWithSeed,instruction.data);return{currentAuthorityDerivedKeyBasePubkey:instruction.keys[2].pubkey,currentAuthorityDerivedKeyOwnerPubkey:new PublicKey(currentAuthorityDerivedKeyOwnerPubkey),currentAuthorityDerivedKeySeed:currentAuthorityDerivedKeySeed,newAuthorizedPubkey:new PublicKey(newAuthorized),voteAuthorizationType:{index:voteAuthorizationType},votePubkey:instruction.keys[0].pubkey}}static decodeWithdraw(instruction){this.checkProgramId(instruction.programId);this.checkKeyLength(instruction.keys,3);const{lamports:lamports}=decodeData$1(VOTE_INSTRUCTION_LAYOUTS.Withdraw,instruction.data);return{votePubkey:instruction.keys[0].pubkey,authorizedWithdrawerPubkey:instruction.keys[2].pubkey,lamports:lamports,toPubkey:instruction.keys[1].pubkey}}static checkProgramId(programId){if(!programId.equals(VoteProgram.programId)){throw new Error("invalid instruction; programId is not VoteProgram")}}static checkKeyLength(keys,expectedLength){if(keys.lengthcurrentVoteAccountBalance-rentExemptMinimum){throw new Error("Withdraw will leave vote account with insufficient funds.")}return VoteProgram.withdraw(params)}static updateValidatorIdentity(params){const{votePubkey:votePubkey,authorizedWithdrawerPubkey:authorizedWithdrawerPubkey,nodePubkey:nodePubkey}=params;const type=VOTE_INSTRUCTION_LAYOUTS.UpdateValidatorIdentity;const data=encodeData(type);const keys=[{pubkey:votePubkey,isSigner:false,isWritable:true},{pubkey:nodePubkey,isSigner:true,isWritable:false},{pubkey:authorizedWithdrawerPubkey,isSigner:true,isWritable:false}];return(new Transaction).add({keys:keys,programId:this.programId,data:data})}}VoteProgram.programId=new PublicKey("Vote111111111111111111111111111111111111111");VoteProgram.space=3762;const VALIDATOR_INFO_KEY=new PublicKey("Va1idator1nfo111111111111111111111111111111");const InfoString=type({name:string(),website:optional(string()),details:optional(string()),iconUrl:optional(string()),keybaseUsername:optional(string())});class ValidatorInfo{constructor(key,info){this.key=void 0;this.info=void 0;this.key=key;this.info=info}static fromConfigData(buffer){let byteArray=[...buffer];const configKeyCount=decodeLength(byteArray);if(configKeyCount!==2)return null;const configKeys=[];for(let i=0;i<2;i++){const publicKey=new PublicKey(guardedSplice(byteArray,0,PUBLIC_KEY_LENGTH));const isSigner=guardedShift(byteArray)===1;configKeys.push({publicKey:publicKey,isSigner:isSigner})}if(configKeys[0].publicKey.equals(VALIDATOR_INFO_KEY)){if(configKeys[1].isSigner){const rawInfo=rustString().decode(bufferExports.Buffer.from(byteArray));const info=JSON.parse(rawInfo);assert(info,InfoString);return new ValidatorInfo(configKeys[1].publicKey,info)}}return null}}const VOTE_PROGRAM_ID=new PublicKey("Vote111111111111111111111111111111111111111");const VoteAccountLayout=LayoutExports.struct([publicKey("nodePubkey"),publicKey("authorizedWithdrawer"),LayoutExports.u8("commission"),LayoutExports.nu64(),LayoutExports.seq(LayoutExports.struct([LayoutExports.nu64("slot"),LayoutExports.u32("confirmationCount")]),LayoutExports.offset(LayoutExports.u32(),-8),"votes"),LayoutExports.u8("rootSlotValid"),LayoutExports.nu64("rootSlot"),LayoutExports.nu64(),LayoutExports.seq(LayoutExports.struct([LayoutExports.nu64("epoch"),publicKey("authorizedVoter")]),LayoutExports.offset(LayoutExports.u32(),-8),"authorizedVoters"),LayoutExports.struct([LayoutExports.seq(LayoutExports.struct([publicKey("authorizedPubkey"),LayoutExports.nu64("epochOfLastAuthorizedSwitch"),LayoutExports.nu64("targetEpoch")]),32,"buf"),LayoutExports.nu64("idx"),LayoutExports.u8("isEmpty")],"priorVoters"),LayoutExports.nu64(),LayoutExports.seq(LayoutExports.struct([LayoutExports.nu64("epoch"),LayoutExports.nu64("credits"),LayoutExports.nu64("prevCredits")]),LayoutExports.offset(LayoutExports.u32(),-8),"epochCredits"),LayoutExports.struct([LayoutExports.nu64("slot"),LayoutExports.nu64("timestamp")],"lastTimestamp")]);class VoteAccount{constructor(args){this.nodePubkey=void 0;this.authorizedWithdrawer=void 0;this.commission=void 0;this.rootSlot=void 0;this.votes=void 0;this.authorizedVoters=void 0;this.priorVoters=void 0;this.epochCredits=void 0;this.lastTimestamp=void 0;this.nodePubkey=args.nodePubkey;this.authorizedWithdrawer=args.authorizedWithdrawer;this.commission=args.commission;this.rootSlot=args.rootSlot;this.votes=args.votes;this.authorizedVoters=args.authorizedVoters;this.priorVoters=args.priorVoters;this.epochCredits=args.epochCredits;this.lastTimestamp=args.lastTimestamp}static fromAccountData(buffer){const versionOffset=4;const va=VoteAccountLayout.decode(toBuffer(buffer),versionOffset);let rootSlot=va.rootSlot;if(!va.rootSlotValid){rootSlot=null}return new VoteAccount({nodePubkey:new PublicKey(va.nodePubkey),authorizedWithdrawer:new PublicKey(va.authorizedWithdrawer),commission:va.commission,votes:va.votes,rootSlot:rootSlot,authorizedVoters:va.authorizedVoters.map(parseAuthorizedVoter),priorVoters:getPriorVoters(va.priorVoters),epochCredits:va.epochCredits,lastTimestamp:va.lastTimestamp})}}function parseAuthorizedVoter({authorizedVoter:authorizedVoter,epoch:epoch}){return{epoch:epoch,authorizedVoter:new PublicKey(authorizedVoter)}}function parsePriorVoters({authorizedPubkey:authorizedPubkey,epochOfLastAuthorizedSwitch:epochOfLastAuthorizedSwitch,targetEpoch:targetEpoch}){return{authorizedPubkey:new PublicKey(authorizedPubkey),epochOfLastAuthorizedSwitch:epochOfLastAuthorizedSwitch,targetEpoch:targetEpoch}}function getPriorVoters({buf:buf,idx:idx,isEmpty:isEmpty}){if(isEmpty){return[]}return[...buf.slice(idx+1).map(parsePriorVoters),...buf.slice(0,idx).map(parsePriorVoters)]}const endpoint={http:{devnet:"http://api.devnet.solana.com",testnet:"http://api.testnet.solana.com","mainnet-beta":"http://api.mainnet-beta.solana.com/"},https:{devnet:"https://api.devnet.solana.com",testnet:"https://api.testnet.solana.com","mainnet-beta":"https://api.mainnet-beta.solana.com/"}};function clusterApiUrl(cluster,tls){const key=tls===false?"http":"https";if(!cluster){return endpoint[key]["devnet"]}const url=endpoint[key][cluster];if(!url){throw new Error(`Unknown ${key} cluster: ${cluster}`)}return url}async function sendAndConfirmRawTransaction(connection,rawTransaction,confirmationStrategyOrConfirmOptions,maybeConfirmOptions){let confirmationStrategy;let options;if(confirmationStrategyOrConfirmOptions&&Object.prototype.hasOwnProperty.call(confirmationStrategyOrConfirmOptions,"lastValidBlockHeight")){confirmationStrategy=confirmationStrategyOrConfirmOptions;options=maybeConfirmOptions}else if(confirmationStrategyOrConfirmOptions&&Object.prototype.hasOwnProperty.call(confirmationStrategyOrConfirmOptions,"nonceValue")){confirmationStrategy=confirmationStrategyOrConfirmOptions;options=maybeConfirmOptions}else{options=confirmationStrategyOrConfirmOptions}const sendOptions=options&&{skipPreflight:options.skipPreflight,preflightCommitment:options.preflightCommitment||options.commitment,minContextSlot:options.minContextSlot};const signature=await connection.sendRawTransaction(rawTransaction,sendOptions);const commitment=options&&options.commitment;const confirmationPromise=confirmationStrategy?connection.confirmTransaction(confirmationStrategy,commitment):connection.confirmTransaction(signature,commitment);const status=(await confirmationPromise).value;if(status.err){if(signature!=null){throw new SendTransactionError({action:sendOptions?.skipPreflight?"send":"simulate",signature:signature,transactionMessage:`Status: (${JSON.stringify(status)})`})}throw new Error(`Raw transaction ${signature} failed (${JSON.stringify(status)})`)}return signature}const LAMPORTS_PER_SOL=1e9;exports.Account=Account;exports.AddressLookupTableAccount=AddressLookupTableAccount;exports.AddressLookupTableInstruction=AddressLookupTableInstruction;exports.AddressLookupTableProgram=AddressLookupTableProgram;exports.Authorized=Authorized;exports.BLOCKHASH_CACHE_TIMEOUT_MS=BLOCKHASH_CACHE_TIMEOUT_MS;exports.BPF_LOADER_DEPRECATED_PROGRAM_ID=BPF_LOADER_DEPRECATED_PROGRAM_ID;exports.BPF_LOADER_PROGRAM_ID=BPF_LOADER_PROGRAM_ID;exports.BpfLoader=BpfLoader;exports.COMPUTE_BUDGET_INSTRUCTION_LAYOUTS=COMPUTE_BUDGET_INSTRUCTION_LAYOUTS;exports.ComputeBudgetInstruction=ComputeBudgetInstruction;exports.ComputeBudgetProgram=ComputeBudgetProgram;exports.Connection=Connection;exports.Ed25519Program=Ed25519Program;exports.Enum=Enum;exports.EpochSchedule=EpochSchedule;exports.FeeCalculatorLayout=FeeCalculatorLayout;exports.Keypair=Keypair;exports.LAMPORTS_PER_SOL=LAMPORTS_PER_SOL;exports.LOOKUP_TABLE_INSTRUCTION_LAYOUTS=LOOKUP_TABLE_INSTRUCTION_LAYOUTS;exports.Loader=Loader;exports.Lockup=Lockup;exports.MAX_SEED_LENGTH=MAX_SEED_LENGTH;exports.Message=Message;exports.MessageAccountKeys=MessageAccountKeys;exports.MessageV0=MessageV0;exports.NONCE_ACCOUNT_LENGTH=NONCE_ACCOUNT_LENGTH;exports.NonceAccount=NonceAccount;exports.PACKET_DATA_SIZE=PACKET_DATA_SIZE;exports.PUBLIC_KEY_LENGTH=PUBLIC_KEY_LENGTH;exports.PublicKey=PublicKey;exports.SIGNATURE_LENGTH_IN_BYTES=SIGNATURE_LENGTH_IN_BYTES;exports.SOLANA_SCHEMA=SOLANA_SCHEMA;exports.STAKE_CONFIG_ID=STAKE_CONFIG_ID;exports.STAKE_INSTRUCTION_LAYOUTS=STAKE_INSTRUCTION_LAYOUTS;exports.SYSTEM_INSTRUCTION_LAYOUTS=SYSTEM_INSTRUCTION_LAYOUTS;exports.SYSVAR_CLOCK_PUBKEY=SYSVAR_CLOCK_PUBKEY;exports.SYSVAR_EPOCH_SCHEDULE_PUBKEY=SYSVAR_EPOCH_SCHEDULE_PUBKEY;exports.SYSVAR_INSTRUCTIONS_PUBKEY=SYSVAR_INSTRUCTIONS_PUBKEY;exports.SYSVAR_RECENT_BLOCKHASHES_PUBKEY=SYSVAR_RECENT_BLOCKHASHES_PUBKEY;exports.SYSVAR_RENT_PUBKEY=SYSVAR_RENT_PUBKEY;exports.SYSVAR_REWARDS_PUBKEY=SYSVAR_REWARDS_PUBKEY;exports.SYSVAR_SLOT_HASHES_PUBKEY=SYSVAR_SLOT_HASHES_PUBKEY;exports.SYSVAR_SLOT_HISTORY_PUBKEY=SYSVAR_SLOT_HISTORY_PUBKEY;exports.SYSVAR_STAKE_HISTORY_PUBKEY=SYSVAR_STAKE_HISTORY_PUBKEY;exports.Secp256k1Program=Secp256k1Program;exports.SendTransactionError=SendTransactionError;exports.SolanaJSONRPCError=SolanaJSONRPCError;exports.SolanaJSONRPCErrorCode=SolanaJSONRPCErrorCode;exports.StakeAuthorizationLayout=StakeAuthorizationLayout;exports.StakeInstruction=StakeInstruction;exports.StakeProgram=StakeProgram;exports.Struct=Struct$1;exports.SystemInstruction=SystemInstruction;exports.SystemProgram=SystemProgram;exports.Transaction=Transaction;exports.TransactionExpiredBlockheightExceededError=TransactionExpiredBlockheightExceededError;exports.TransactionExpiredNonceInvalidError=TransactionExpiredNonceInvalidError;exports.TransactionExpiredTimeoutError=TransactionExpiredTimeoutError;exports.TransactionInstruction=TransactionInstruction;exports.TransactionMessage=TransactionMessage;exports.TransactionStatus=TransactionStatus;exports.VALIDATOR_INFO_KEY=VALIDATOR_INFO_KEY;exports.VERSION_PREFIX_MASK=VERSION_PREFIX_MASK;exports.VOTE_PROGRAM_ID=VOTE_PROGRAM_ID;exports.ValidatorInfo=ValidatorInfo;exports.VersionedMessage=VersionedMessage;exports.VersionedTransaction=VersionedTransaction;exports.VoteAccount=VoteAccount;exports.VoteAuthorizationLayout=VoteAuthorizationLayout;exports.VoteInit=VoteInit;exports.VoteInstruction=VoteInstruction;exports.VoteProgram=VoteProgram;exports.clusterApiUrl=clusterApiUrl;exports.sendAndConfirmRawTransaction=sendAndConfirmRawTransaction;exports.sendAndConfirmTransaction=sendAndConfirmTransaction;return exports}({}); +//# sourceMappingURL=index.iife.min.js.map diff --git a/shine-UI/promo-code-generator.html b/shine-UI/promo-code-generator.html new file mode 100644 index 0000000..2669ebc --- /dev/null +++ b/shine-UI/promo-code-generator.html @@ -0,0 +1,555 @@ + + + + + + SHiNE Promo Code Generator + + + +
+

Генератор promo-кодов

+

+ Полностью автономный HTML-файл. Можно открыть локально без сервера и без внешних зависимостей. + Подписывается строка shine_promo_v1:<login>, а на выходе получается promo-код + формата 1seller-signatureBase58. +

+ +
+
+ Режим подписи +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ Подключённый кошелёк +
не подключен
+
+ +
+
+ + + +
+ Подписываемое сообщение +
+
+ +
+
+ + +
+
+ +
+ + +
+
+ +

Готово. Можно работать полностью локально.

+
+ + + + diff --git a/shine-UI/server-ui/create-server-pda.html b/shine-UI/server-ui/create-server-pda.html index ecffab0..e58cbb5 100644 --- a/shine-UI/server-ui/create-server-pda.html +++ b/shine-UI/server-ui/create-server-pda.html @@ -48,8 +48,8 @@

Параметры Solana

- -
devnet: https://api.devnet.solana.com · mainnet: https://api.mainnet-beta.solana.com
+ +
mainnet: https://solana-rpc.publicnode.com · devnet: https://api.devnet.solana.com
@@ -61,11 +61,11 @@
Только a-z, 0-9, _ · без точки · макс. 20 символов
- +
- +
@@ -121,7 +121,7 @@
Положите SOL на этот адрес перед регистрацией:
Это Solana-адрес device-ключа (public key в base58 = Solana-адрес). С него оплачиваются и создание, и обновление PDA.
- +
Баланс device ещё не запрашивался.
diff --git a/shine-UI/server-ui/js/create-server-pda-page.js b/shine-UI/server-ui/js/create-server-pda-page.js index beaaab0..3733f06 100644 --- a/shine-UI/server-ui/js/create-server-pda-page.js +++ b/shine-UI/server-ui/js/create-server-pda-page.js @@ -16,7 +16,7 @@ import { validateLoginOrThrow, wireDeviceAddressPreview, } from './server-ui-shared.js'; -import { defaultServerAddress, defaultServerLogin } from '../../js/deploy-config.js'; +import { defaultServerAddress, defaultServerLogin, defaultSolanaEndpoint } from '../../js/deploy-config.js'; const fieldMap = { masterSecret: 'masterSecret', @@ -37,9 +37,13 @@ wireDeviceAddressPreview(fieldMap); $('password').value = ''; $('login').placeholder = defaultServerLogin; $('serverAddress').placeholder = defaultServerAddress; +$('endpoint').value = defaultSolanaEndpoint; $('btnTopupDevnet').addEventListener('click', () => { try { + if (!String($('endpoint').value || '').includes('devnet')) { + throw new Error('Автопополнение доступно только для devnet. Для mainnet пополните device-кошелёк вручную.'); + } openDevnetTopup($('devPub').value); } catch (error) { setStatus($('status'), error?.message || String(error), 'error'); diff --git a/shine-UI/server-ui/js/read-pda-page.js b/shine-UI/server-ui/js/read-pda-page.js index 7cc8fb7..5c21041 100644 --- a/shine-UI/server-ui/js/read-pda-page.js +++ b/shine-UI/server-ui/js/read-pda-page.js @@ -1,6 +1,7 @@ import { readShineUserPdaByRef } from '../../js/services/shine-user-pda-service.js'; import { bytesToBase58 } from '../../js/services/crypto-utils.js'; import { $, clearStatus, formatBigInt, formatTimestamp, setStatus } from './server-ui-shared.js'; +import { defaultSolanaEndpoint } from '../../js/deploy-config.js'; function hex(bytes) { const data = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []); @@ -12,6 +13,10 @@ function base58(bytes) { return bytesToBase58(data); } +if ($('endpoint')) { + $('endpoint').value = defaultSolanaEndpoint; +} + function renderRows(rows) { const container = $('summaryRows'); container.innerHTML = ''; diff --git a/shine-UI/server-ui/js/update-server-pda-page.js b/shine-UI/server-ui/js/update-server-pda-page.js index 4207e30..69e82eb 100644 --- a/shine-UI/server-ui/js/update-server-pda-page.js +++ b/shine-UI/server-ui/js/update-server-pda-page.js @@ -22,7 +22,7 @@ import { validateLoginOrThrow, wireDeviceAddressPreview, } from './server-ui-shared.js'; -import { defaultServerAddress, defaultServerLogin } from '../../js/deploy-config.js'; +import { defaultServerAddress, defaultServerLogin, defaultSolanaEndpoint } from '../../js/deploy-config.js'; const fieldMap = { masterSecret: 'masterSecret', @@ -100,10 +100,14 @@ wireDeviceAddressPreview(fieldMap); $('password').value = ''; $('login').placeholder = defaultServerLogin; $('serverAddress').placeholder = defaultServerAddress; +$('endpoint').value = defaultSolanaEndpoint; resetExpectedKeysUi(); $('btnTopupDevnet').addEventListener('click', () => { try { + if (!String($('endpoint').value || '').includes('devnet')) { + throw new Error('Автопополнение доступно только для devnet. Для mainnet пополните device-кошелёк вручную.'); + } openDevnetTopup($('devPub').value); } catch (error) { setStatus($('status'), error?.message || String(error), 'error'); diff --git a/shine-UI/server-ui/read-pda.html b/shine-UI/server-ui/read-pda.html index 08fcb27..b92701d 100644 --- a/shine-UI/server-ui/read-pda.html +++ b/shine-UI/server-ui/read-pda.html @@ -52,7 +52,7 @@

Параметры Solana

- +
diff --git a/shine-UI/server-ui/update-server-pda.html b/shine-UI/server-ui/update-server-pda.html index 6b695ba..123fb40 100644 --- a/shine-UI/server-ui/update-server-pda.html +++ b/shine-UI/server-ui/update-server-pda.html @@ -56,7 +56,7 @@

Параметры Solana

- +
@@ -86,11 +86,11 @@

Новые параметры сервера

- +
- +
@@ -142,7 +142,7 @@
Положите SOL на этот адрес перед обновлением:
Это Solana-адрес (base58) device-ключа. С него оплачивается транзакция.
- +
Баланс device ещё не запрашивался.
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index b187467..a6df7f6 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -44,6 +44,26 @@ justify-content: flex-end; } +.chat-header-icon-btn { + min-width: 42px; + width: 42px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + line-height: 1; +} + +.chat-header-call-btn { + border-radius: 12px; +} + +.chat-header-menu-btn { + border-radius: 12px; + font-size: 24px; +} + .icon-btn, .text-btn, .primary-btn, @@ -1197,6 +1217,25 @@ gap: 10px; } +.key-storage-option { + gap: 6px; +} + +.key-storage-option__description { + margin: 0; +} + +.key-storage-note { + margin: 0; + line-height: 1.45; +} + +.key-storage-note--strong { + font-size: 17px; + font-weight: 700; + color: #eef4ff; +} + .key-card { padding: 12px; border-radius: var(--radius-md); @@ -1521,6 +1560,35 @@ word-break: break-word; } +.bubble-reply-preview { + display: block; + width: 100%; + margin: 0 0 8px; + padding: 8px 10px; + border: 1px solid rgba(142, 173, 232, 0.22); + border-radius: 10px; + background: rgba(11, 22, 41, 0.34); + text-align: left; + color: inherit; + cursor: pointer; +} + +.bubble-reply-preview-author { + font-size: 12px; + font-weight: 700; + color: rgba(250, 221, 155, 0.94); +} + +.bubble-reply-preview-text { + margin-top: 3px; + font-size: 12px; + line-height: 1.3; + color: rgba(223, 233, 250, 0.86); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .bubble-meta { margin-top: 4px; display: flex; @@ -1563,11 +1631,52 @@ .bubble.call-tech { max-width: 90%; justify-self: center; - border-radius: 10px; - border: 1px solid rgba(131, 162, 223, 0.32); - background: rgba(40, 55, 84, 0.55); + border-radius: 16px; + border: 1px solid rgba(132, 170, 231, 0.28); + background: rgba(24, 36, 58, 0.72); color: #dce8ff; font-size: 12px; + padding: 12px 14px; +} + +.bubble-call-card { + display: grid; + gap: 4px; + min-width: min(280px, 72vw); +} + +.bubble-call-head { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.bubble-call-icon { + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 999px; + background: rgba(80, 170, 255, 0.18); + border: 1px solid rgba(123, 191, 255, 0.28); + color: #d9ecff; + font-size: 15px; +} + +.bubble-call-title { + font-size: 14px; + font-weight: 700; + color: #eef6ff; +} + +.bubble-call-line { + font-size: 12px; + line-height: 1.35; + color: rgba(230, 240, 255, 0.94); +} + +.bubble-call-line--muted { + color: rgba(188, 207, 238, 0.78); } .chat-input { @@ -2027,20 +2136,23 @@ textarea.input { } .network-header-overlay { - position: absolute; - top: max(8px, env(safe-area-inset-top)); + position: sticky; + top: max(14px, calc(env(safe-area-inset-top) + 8px)); left: max(8px, env(safe-area-inset-left)); right: max(8px, env(safe-area-inset-right)); box-sizing: border-box; margin-bottom: 0; z-index: 12; pointer-events: none; + padding: 9px 12px 0; } .network-header-overlay.page-header { - width: auto; + display: grid; + grid-template-columns: minmax(42px, 1fr) auto minmax(58px, 1fr); + align-items: center; margin-bottom: 0; - gap: 6px; + gap: 10px; } .network-header-overlay .header-left, @@ -2050,21 +2162,31 @@ textarea.input { gap: 6px; } +.network-header-overlay .header-left { + justify-content: flex-start; +} + +.network-header-overlay .header-actions { + justify-content: flex-end; +} + .network-header-overlay .icon-btn { pointer-events: auto; - min-height: 30px; - padding: 5px 8px; - font-size: 11px; + min-height: 32px; + padding: 6px 10px; + font-size: 12px; border-radius: 10px; } .network-header-overlay .page-title { flex: 1 1 auto; font-size: 16px; + line-height: 32px; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + text-align: center; text-shadow: 0 1px 8px rgba(0, 0, 0, 0.45); } @@ -4541,9 +4663,8 @@ html, body { overflow-x: hidden; } min-width: 0; font-size: 12px; line-height: 1.35; - white-space: nowrap; + white-space: pre-line; overflow: hidden; - text-overflow: ellipsis; } .dm-edit-banner__close { @@ -4823,6 +4944,22 @@ html, body { overflow-x: hidden; } background: rgba(112, 28, 28, 0.22); } +.dm-confirm-check { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(17, 26, 46, 0.62); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #d9e6ff; + font-size: 14px; +} + +.dm-confirm-check input { + margin-top: 2px; +} + .speech-actions-top { display: grid; grid-template-columns: 1fr 1fr; @@ -5059,6 +5196,14 @@ html, body { overflow-x: hidden; } color: #f6deb0; } +.registration-password-toggle { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + border-radius: 14px; +} + .registration-progress { width: 100%; height: 10px; diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css index 91d90b3..4b44cc0 100644 --- a/shine-UI/styles/layout.css +++ b/shine-UI/styles/layout.css @@ -65,6 +65,18 @@ body::before { bottom: 0; padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%); + transition: opacity 0.18s ease, transform 0.18s ease; +} + +body.chat-keyboard-open .screen-content { + bottom: 0; + padding-bottom: calc(14px + env(safe-area-inset-bottom)); +} + +body.chat-keyboard-open .toolbar-slot { + opacity: 0; + pointer-events: none; + transform: translateY(calc(100% + env(safe-area-inset-bottom))); } .connection-retry-banner { diff --git a/shine-solana/shine/Anchor.toml b/shine-solana/shine/Anchor.toml index 8ee249d..0846c26 100644 --- a/shine-solana/shine/Anchor.toml +++ b/shine-solana/shine/Anchor.toml @@ -6,21 +6,26 @@ resolution = true skip-lint = false [programs.devnet] -shine_payments = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW" -shine_users = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ" -shine_login_guard = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo" +shine_payments = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v" +shine_users = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6" +shine_login_guard = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh" [programs.localnet] -shine_payments = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW" -shine_users = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ" -shine_login_guard = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo" +shine_payments = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v" +shine_users = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6" +shine_login_guard = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh" + +[programs.mainnet] +shine_login_guard = "SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh" +shine_users = "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6" +shine_payments = "SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v" [registry] url = "https://api.apr.dev" [provider] -cluster = "devnet" -wallet = "~/.config/solana/id.json" +cluster = "mainnet" +wallet = "/home/ai/work/SHiNE/keys_for_create_DAO/dao-issuer.json" [workspace] members = ["programs/shine_users", "programs/shine_payments", "programs/shine_login_guard"] diff --git a/shine-solana/shine/Cargo.lock b/shine-solana/shine/Cargo.lock index f660f0e..b92b97e 100644 --- a/shine-solana/shine/Cargo.lock +++ b/shine-solana/shine/Cargo.lock @@ -14,6 +14,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anchor-attribute-access-control" version = "0.31.1" @@ -140,6 +149,7 @@ dependencies = [ "anchor-derive-accounts", "anchor-derive-serde", "anchor-derive-space", + "anchor-lang-idl", "base64 0.21.7", "bincode", "borsh 0.10.4", @@ -157,6 +167,7 @@ dependencies = [ "anchor-lang-idl-spec", "anyhow", "heck", + "regex", "serde", "serde_json", "sha2 0.10.9", @@ -180,6 +191,7 @@ checksum = "5dc7a6d90cc643df0ed2744862cdf180587d1e5d28936538c18fc8908489ed67" dependencies = [ "anyhow", "bs58", + "cargo_toml", "heck", "proc-macro2", "quote", @@ -398,6 +410,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cargo_toml" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a98356df42a2eb1bd8f1793ae4ee4de48e384dd974ce5eac8eee802edb7492be" +dependencies = [ + "serde", + "toml 0.8.22", +] + [[package]] name = "cc" version = "1.2.23" @@ -900,7 +922,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" dependencies = [ - "toml", + "toml 0.5.11", ] [[package]] @@ -1044,6 +1066,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1118,6 +1169,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "sha2" version = "0.9.9" @@ -1156,6 +1216,7 @@ dependencies = [ name = "shine_login_guard" version = "0.1.0" dependencies = [ + "anchor-lang", "solana-program", ] @@ -2082,11 +2143,26 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + [[package]] name = "toml_datetime" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] [[package]] name = "toml_edit" @@ -2095,10 +2171,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ "indexmap", + "serde", + "serde_spanned", "toml_datetime", + "toml_write", "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.18.0" diff --git a/shine-solana/shine/build.gradle b/shine-solana/shine/build.gradle index c0408e3..d6fa8d3 100644 --- a/shine-solana/shine/build.gradle +++ b/shine-solana/shine/build.gradle @@ -46,5 +46,5 @@ tasks.register("checkUiRemote", Exec) { "grep -n 'test-solana-tickets.shineup.me\\|test-solana-tickets.shiningpeople.ru' /home/player/SHiNE/caddy/Caddyfile; " + "echo; " + "echo 'Program ID в загруженных html:'; " + - "grep -R -n 'c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW' /home/player/sites/test-solana-tickets.shineup.me/*.html" + "grep -R -n 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v' /home/player/sites/test-solana-tickets.shineup.me/*.html" } diff --git a/shine-solana/shine/create-SHiNE-DAO/README.md b/shine-solana/shine/create-SHiNE-DAO/README.md new file mode 100644 index 0000000..5cf889c --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/README.md @@ -0,0 +1,301 @@ +# create-SHiNE-DAO + +Чистый актуальный комплект для создания SHiNE DAO с переносимым governance mint (`Liquid`) и для работы с NFT, которые: + +1. можно выпускать через голосование DAO; +2. можно сжигать через голосование DAO; +3. в будущем можно будет сжигать через отдельную быструю программу-исполнитель только для одного конкретного `mint`. + +Этот набор сделан как production-style папка: + +- без старых `runs/`; +- без старых `keypairs/`; +- без legacy-скриптов; +- без смешивания нескольких исторических подходов. + +## Что внутри + +- `dao.config.env.example` - шаблон конфига для создания mint и DAO в три шага. +- `scripts/01_create_governance_mint.js` - создание governance mint и metadata без депозита в Realm. +- `scripts/02_create_dao_with_governance_nfts.js` - создание `Realm / TokenOwnerRecord / Governance / Treasury` без deposit и без передачи authority. +- `scripts/03_transfer_dao_authorities.js` - передача `mint authority`, `freeze authority`, `realm authority` и `metadata update authority` на DAO. +- `nft-flow.config.env.example` - шаблон конфига для выпуска и burn NFT через уже созданное DAO. +- `scripts/01_check_dao_config.sh` - проверка конфига перед созданием DAO из существующего mint. +- `scripts/04_prepare_second_voter.js` - подготовка второго голосующего. +- `scripts/05_create_empty_nft_template_with_metadata.js` - создание NFT mint-шаблона с metadata и картинкой. +- `scripts/06_propose_vote_mint_nft.js` - proposal/sign/vote на mint NFT. +- `scripts/07_execute_mint_nft.js` - execute mint proposal. +- `scripts/08_propose_vote_burn_nft.js` - proposal/sign/vote на burn NFT. +- `scripts/09_execute_burn_nft.js` - execute burn proposal. +- `docs/FUTURE_FAST_BURN_EXECUTOR.md` - описание будущей программы для быстрого burn одного конкретного `mint`. + +## Подтвержденная база, на которой собран комплект + +Комплект собран из тех сценариев, которые уже были подтверждены на devnet: + +- DAO с `Realm / Governance / Native Treasury`; +- mint NFT через DAO; +- burn NFT через DAO. + +## Важное ограничение текущей схемы burn через DAO + +Текущий стандартный burn через proposal работает так: + +1. в proposal вшивается конкретная burn-инструкция; +2. в ней зашит конкретный token account, из которого надо сжигать NFT; +3. если владелец успеет переложить NFT в другой token account до `execute`, этот `execute` может не пройти. + +То есть: + +- burn определяется по `mint`; +- но исполняется из конкретного текущего token account; +- если токен двигают между proposal и execute, нужна более быстрая схема. + +Именно для этого отдельно описан future-подход с одноразовым fast-burn executor. + +## Что нужно заранее + +В корне Solana-модуля: + +```bash +cd shine-solana/shine +npm install +``` + +Нужны зависимости: + +- `@solana/web3.js` +- `@solana/spl-token` +- `@solana/spl-governance` +- `@metaplex-foundation/mpl-token-metadata` +- `@metaplex-foundation/umi` + +Также должны быть установлены CLI: + +- `solana` +- `solana-keygen` +- `node` + +## Быстрый сценарий: создать mint, раздать токены, создать DAO и затем передать все права на DAO + +### 1. Создать конфиг + +```bash +cp create-SHiNE-DAO/dao.config.env.example create-SHiNE-DAO/dao.config.env +``` + +Заполнить: + +- имя DAO; +- путь к keypair создателя; +- имя и символ governance mint; +- при необходимости `DAO_GOV_MINT_KEYPAIR` для красивого или фиксированного mint address; +- `DAO_GOV_TOKEN_METADATA_URI` и `DAO_GOV_TOKEN_IMAGE_URL`. + +Обычно используются два keypair-файла: + +- `DAO_ISSUER_KEYPAIR` - временный кошелёк создателя/оператора; +- `DAO_GOV_MINT_KEYPAIR` - keypair самого mint, если нужен красивый адрес governance mint. + +Сгенерировать их можно так: + +```bash +solana-keygen new --outfile ./dao-issuer.json +solana-keygen new --outfile ./dao-gov-mint.json +``` + +Публичные ключи посмотреть так: + +```bash +solana-keygen pubkey ./dao-issuer.json +solana-keygen pubkey ./dao-gov-mint.json +``` + +Практическое правило: + +- первый keypair обычно временный, его красивый адрес не важен; +- красивым обычно имеет смысл делать именно `DAO_GOV_MINT_KEYPAIR`, потому что это постоянный публичный адрес governance mint. + +### 2. Создать governance mint + +```bash +node create-SHiNE-DAO/scripts/01_create_governance_mint.js ./create-SHiNE-DAO/dao.config.env +``` + +Этот шаг: + +1. создаёт mint; +2. прикрепляет metadata на Arweave; +3. оставляет у кошелька создателя: + - `mint authority` + - `freeze authority` + - `metadata update authority` + +Актуальные Arweave-ссылки для governance mint сейчас такие: + +- image: `https://arweave.net/rPGo-uYIFoLxMBVTGyPr5K6fuF0y9xL-8TlAaZyBJgY` +- metadata: `https://arweave.net/PdupvtRhj_DTBjTLcjD7Z5GSiEcFUf01GHaYMDs6gGw` + +### 3. Раздать токены вручную + +```bash +spl-token mint 1 +spl-token mint 1 +``` + +После этого можно руками наминтить управляющие токены на нужные кошельки. +На этом этапе токен ещё обычный и переносимый. + +### 4. Проверить конфиг DAO + +```bash +./create-SHiNE-DAO/scripts/01_check_dao_config.sh ./create-SHiNE-DAO/dao.config.env +``` + +Перед проверкой в `dao.config.env` нужно указать: + +- `DAO_GOV_TOKEN_MINT` - уже созданный mint; +- `DAO_REALM_NAME` - новое имя DAO; +- `DAO_ISSUER_KEYPAIR` - кошелёк создателя; +- `DAO_VOTING_TIME_SEC` - по умолчанию `86400` секунд (1 день); +- `DAO_APPROVAL_THRESHOLD_PERCENT`. + +### 5. Создать DAO + +```bash +node create-SHiNE-DAO/scripts/02_create_dao_with_governance_nfts.js ./create-SHiNE-DAO/dao.config.env +``` + +Этот шаг: + +- создаёт `Realm`; +- создаёт `TokenOwnerRecord` для кошелька создателя без deposit; +- создаёт `Governance` и `Native Treasury`; +- задаёт community token type = `Liquid`; +- не делает deposit токенов; +- не передаёт authority. + +После этого из отчета взять: + +- `realm` +- `governance` +- `communityMint` +- `tokenOwnerRecord` + +### 6. Передать все authority на DAO + +```bash +node create-SHiNE-DAO/scripts/03_transfer_dao_authorities.js ./create-SHiNE-DAO/dao.config.env +``` + +Этот шаг передаёт на DAO: + +- `mint authority` +- `freeze authority` +- `realm authority` +- `metadata update authority` + +После этого у создателя не должно остаться административных прав на governance mint и Realm. + +### 7. Создать конфиг NFT-flow + +```bash +cp create-SHiNE-DAO/nft-flow.config.env.example create-SHiNE-DAO/nft-flow.config.env +``` + +Подставить: + +- `REALM` +- `GOVERNANCE` +- `GOVERNING_MINT` +- `MAIN_KEYPAIR` +- `VOTER2_KEYPAIR` +- metadata URI и картинку для целевого NFT. + +### 8. Подготовить второго голосующего + +```bash +node create-SHiNE-DAO/scripts/04_prepare_second_voter.js ./create-SHiNE-DAO/nft-flow.config.env +``` + +### 9. Создать NFT mint-шаблон с metadata + +```bash +node create-SHiNE-DAO/scripts/05_create_empty_nft_template_with_metadata.js ./create-SHiNE-DAO/nft-flow.config.env +``` + +Скрипт вернет `mint` будущего NFT. + +### 10. Поднять proposal на mint NFT + +```bash +node create-SHiNE-DAO/scripts/06_propose_vote_mint_nft.js \ + ./create-SHiNE-DAO/nft-flow.config.env \ + \ + +``` + +### 11. Выполнить mint proposal + +```bash +node create-SHiNE-DAO/scripts/07_execute_mint_nft.js \ + ./create-SHiNE-DAO/nft-flow.config.env \ + \ + \ + \ + +``` + +### 12. Поднять proposal на burn NFT + +```bash +node create-SHiNE-DAO/scripts/08_propose_vote_burn_nft.js \ + ./create-SHiNE-DAO/nft-flow.config.env \ + \ + +``` + +### 13. Выполнить burn proposal + +```bash +node create-SHiNE-DAO/scripts/09_execute_burn_nft.js \ + ./create-SHiNE-DAO/nft-flow.config.env \ + \ + \ + \ + +``` + +## Что важно про картинку NFT + +У конкретного NFT картинка идет не отдельным on-chain полем, а через metadata JSON по `uri`. + +То есть нужно: + +1. загрузить JSON metadata на Arweave/IPFS/HTTPS; +2. в JSON указать `image`; +3. в конфиге передать `NFT_METADATA_URI`; +4. `NFT_IMAGE_URL` хранить для явной проверки и документации. + +## Что важно про стартовые governance NFT + +В новом трёхшаговом flow governance mint создается отдельно, токены можно распределить вручную до создания DAO, а затем отдельным шагом DAO получает права на: + +- `mint authority` +- `freeze authority` +- `realm authority` +- `metadata update authority` + +Это сделано специально, чтобы: + +1. сначала спокойно распределить токены; +2. затем создать Realm без блокировки стартовых токенов; +3. и только потом окончательно передать контроль DAO. + +## Future + +Если нужен сценарий "хитрый владелец постоянно перекладывает NFT", смотреть: + +- `docs/FUTURE_FAST_BURN_EXECUTOR.md` + +Там описана целевая отдельная программа, которой DAO может выдать одноразовое право сжечь только один конкретный `mint`. diff --git a/shine-solana/shine/create-SHiNE-DAO/dao.config.env.example b/shine-solana/shine/create-SHiNE-DAO/dao.config.env.example new file mode 100644 index 0000000..2057770 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/dao.config.env.example @@ -0,0 +1,36 @@ +# create-SHiNE-DAO / конфиг создания DAO + +# devnet | mainnet-beta | custom rpc url +DAO_CLUSTER="devnet" + +# Для каждого нового запуска должен быть новый realm name. +DAO_REALM_NAME="SHiNE DAO2" + +# Governance NFT коллекция DAO +DAO_GOV_NFT_NAME="SHiNE Governance NFT" +DAO_GOV_NFT_SYMBOL="SHINE-GOV" +DAO_GOV_TOKEN_DESCRIPTION="Токен для голосования в SHiNE DAO. Voting token for SHiNE DAO." + +# Шаг 1: создание mint +# Первый keypair временный: он нужен только как оператор для старта. +# Красивым обычно имеет смысл делать именно mint keypair. +DAO_GOV_MINT_KEYPAIR="/home/ai/work/SHiNE/keys_for_create_DAO/dao-gov-mint.json" + +# Metadata для governance mint +DAO_GOV_TOKEN_METADATA_URI="https://arweave.net/PdupvtRhj_DTBjTLcjD7Z5GSiEcFUf01GHaYMDs6gGw" +DAO_GOV_TOKEN_IMAGE_URL="https://arweave.net/rPGo-uYIFoLxMBVTGyPr5K6fuF0y9xL-8TlAaZyBJgY" + +# Шаг 2: создание DAO из уже существующего mint +DAO_GOV_TOKEN_MINT="" + +# Дефолт для новых DAO - 86400 секунд (1 день) +DAO_VOTING_TIME_SEC="86400" + +# 51..100 +DAO_APPROVAL_THRESHOLD_PERCENT="51" + +# Создатель DAO / временный оператор до передачи authority на DAO +DAO_ISSUER_KEYPAIR="/home/ai/work/SHiNE/keys_for_create_DAO/dao-issuer.json" + +# Governance program (Realms) +SPL_GOVERNANCE_PROGRAM_ID="GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw" diff --git a/shine-solana/shine/create-SHiNE-DAO/dao.config.mainnet.env b/shine-solana/shine/create-SHiNE-DAO/dao.config.mainnet.env new file mode 100644 index 0000000..7485bff --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/dao.config.mainnet.env @@ -0,0 +1,21 @@ +DAO_CLUSTER="mainnet-beta" + +DAO_REALM_NAME="SHiNE DAO" + +DAO_GOV_NFT_NAME="SHiNE Governance NFT" +DAO_GOV_NFT_SYMBOL="SHINE-GOV" +DAO_GOV_TOKEN_DESCRIPTION="Токен для голосования в SHiNE DAO. Voting token for SHiNE DAO." + +DAO_GOV_MINT_KEYPAIR="/home/ai/work/SHiNE/keys_for_create_DAO/dao-gov-mint.json" + +DAO_GOV_TOKEN_METADATA_URI="https://arweave.net/PdupvtRhj_DTBjTLcjD7Z5GSiEcFUf01GHaYMDs6gGw" +DAO_GOV_TOKEN_IMAGE_URL="https://arweave.net/rPGo-uYIFoLxMBVTGyPr5K6fuF0y9xL-8TlAaZyBJgY" + +DAO_GOV_TOKEN_MINT="SHi1KQ9BdJTmzwy5aPJQs8QB4er4Ka8HaJTQcyE4gY1" + +DAO_VOTING_TIME_SEC="86400" +DAO_APPROVAL_THRESHOLD_PERCENT="51" + +DAO_ISSUER_KEYPAIR="/home/ai/work/SHiNE/keys_for_create_DAO/dao-issuer.json" + +SPL_GOVERNANCE_PROGRAM_ID="GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw" diff --git a/shine-solana/shine/create-SHiNE-DAO/docs/FUTURE_FAST_BURN_EXECUTOR.md b/shine-solana/shine/create-SHiNE-DAO/docs/FUTURE_FAST_BURN_EXECUTOR.md new file mode 100644 index 0000000..3a17bcb --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/docs/FUTURE_FAST_BURN_EXECUTOR.md @@ -0,0 +1,67 @@ +# Future Fast Burn Executor + +## Зачем это нужно + +Обычный DAO burn proposal уязвим к сценарию: + +1. proposal уже создан; +2. в него уже зашит конкретный token account; +3. владелец NFT быстро перекладывает токен на другой кошелек; +4. `execute` DAO burn больше не совпадает с актуальным местом хранения. + +Для такого случая нужен отдельный быстрый исполнитель. + +## Идея + +DAO не сжигает токен напрямую через обычную burn-инструкцию, а делает одноразовое разрешение для отдельной программы. + +Эта программа: + +1. получает право только на один конкретный `mint`; +2. получает ограничение по числу исполнений `max_uses = 1`; +3. может иметь `expires_at`; +4. может принимать исполнение только от разрешенного исполнителя; +5. после успешного burn закрывает право навсегда. + +## Что должно быть зашито в разрешении + +- `target_mint` +- `governance_realm` +- `dao_governance` +- `executor_pubkey` +- `max_uses = 1` +- `expires_at` +- `status = active | used | expired | revoked` + +## Как должен работать burn + +1. DAO голосованием создает запись-разрешение. +2. В запись попадает один конкретный `mint`. +3. Быстрый исполнитель вызывает программу. +4. Программа находит текущее место хранения NFT. +5. Программа проверяет, что найден именно нужный `mint`. +6. Программа делает burn. +7. Программа помечает разрешение как `used`. + +## Главная цель + +Исполнитель не должен иметь глобальное право сжигать любые токены. + +Он должен иметь только одноразовое право: + +- либо на один конкретный `mint`; +- либо на маленький заранее зафиксированный список `mint`; +- но не на всю коллекцию и не на весь governance mint. + +## Что это дает + +- DAO принимает политическое решение голосованием; +- исполнение становится быстрым; +- даже если владелец перекладывает NFT, программа сможет сжечь актуальный текущий token account; +- риск "исполнитель спалит всех" резко снижается. + +## Текущее состояние + +В этом комплекте программа fast-burn executor пока не реализована. + +Здесь зафиксирована только правильная целевая архитектура, чтобы потом делать ее как отдельный следующий этап без повторного обсуждения логики. diff --git a/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-01_10-19-45_governance_mint.json b/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-01_10-19-45_governance_mint.json new file mode 100644 index 0000000..68821ac --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-01_10-19-45_governance_mint.json @@ -0,0 +1,14 @@ +{ + "createdAt": "2026-07-01T06:19:45.773Z", + "cluster": "mainnet-beta", + "mint": "SHi1KQ9BdJTmzwy5aPJQs8QB4er4Ka8HaJTQcyE4gY1", + "issuer": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "mintAuthority": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "freezeAuthority": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "metadataUpdateAuthority": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "governanceProgramId": "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw", + "metadataUri": "https://arweave.net/PdupvtRhj_DTBjTLcjD7Z5GSiEcFUf01GHaYMDs6gGw", + "imageUrl": "https://arweave.net/rPGo-uYIFoLxMBVTGyPr5K6fuF0y9xL-8TlAaZyBJgY", + "txMint": "4VV1Q8VC3rayrAqDekoe8fLEtu2G4ZD4k5FQtxzR7oD3UfFEgichYBuwDXRgXpDMmEvpnDSNpdoDQmK1n8Xoefzc", + "txMetadata": "JqKz0LtK5hBw5R/FMI7iwMA9D+oeFssodppWtbNCA+6k+Od1JNrNXAXwY68r9bwiSsppYv+WoT8CYVh63o7NCA==" +} \ No newline at end of file diff --git a/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-01_10-23-52_create_dao_from_existing_mint.json b/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-01_10-23-52_create_dao_from_existing_mint.json new file mode 100644 index 0000000..c7a98ee --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-01_10-23-52_create_dao_from_existing_mint.json @@ -0,0 +1,19 @@ +{ + "createdAt": "2026-07-01T06:23:52.518Z", + "cluster": "mainnet-beta", + "realmName": "SHiNE DAO", + "governanceProgramId": "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw", + "governingMint": "SHi1KQ9BdJTmzwy5aPJQs8QB4er4Ka8HaJTQcyE4gY1", + "issuer": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "issuerAta": "pNjsDn7qiyxjoxRHpo2CKNHvxPcHBwHSL1NSbVaWT19", + "realm": "FcMg5G3RpAYvW3vvztrcMuFXifWYR3HYiTuPfVgwuN4K", + "governance": "9EEcpw6JK74CqMAsQ46LUhKu7AqX274TDuaAdoDwpbBh", + "nativeTreasury": "AcJRevXj3Yux2ecxXromMwwxEpPBz6AN53hH6KdqWJ9A", + "tokenOwnerRecord": "9P4KstYKZyHwtLFcm4LkpfCLaCTyX1p4uPQWzATcpKZq", + "mintAuthorityBefore": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "freezeAuthorityBefore": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "txRealm": "3fccVSRfRYHrMcd138eA4P3paacj936DeH6GctMXCM6aLEFvhiE29X8ATxeyNKDDrCfi8zCTxkYrvdyhXiWtY3dk", + "txTokenOwnerRecord": "5xEwh5EtN2QNensXo8DVktyw5BQSPHkE2kWXnM5mb9g6c3cRpzM8JRp11nT79XbQxuGVLfKQFrbzX1ZLMZG71LaV", + "txGovernanceTreasury": "kbWvrxLHkNWa6Xxd4VuY6WejaqdKb6W2TWo9vh2VuQoovviJ6VCcuf7rTpvyNpyiBC41koJ9kkw3JPRySM5SV67", + "communityTokenType": "Liquid" +} \ No newline at end of file diff --git a/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-08_15-10-08_transfer_dao_authorities.json b/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-08_15-10-08_transfer_dao_authorities.json new file mode 100644 index 0000000..65ceca0 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/local/runs/2026-07-08_15-10-08_transfer_dao_authorities.json @@ -0,0 +1,21 @@ +{ + "createdAt": "2026-07-08T11:10:08.590Z", + "cluster": "mainnet-beta", + "realmName": "SHiNE DAO", + "governanceProgramId": "GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw", + "issuer": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "governingMint": "SHi1KQ9BdJTmzwy5aPJQs8QB4er4Ka8HaJTQcyE4gY1", + "realm": "FcMg5G3RpAYvW3vvztrcMuFXifWYR3HYiTuPfVgwuN4K", + "governance": "9EEcpw6JK74CqMAsQ46LUhKu7AqX274TDuaAdoDwpbBh", + "mintAuthorityBefore": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "freezeAuthorityBefore": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "metadataUpdateAuthorityBefore": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "realmAuthorityBefore": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "mintAuthorityAfter": "9EEcpw6JK74CqMAsQ46LUhKu7AqX274TDuaAdoDwpbBh", + "freezeAuthorityAfter": "9EEcpw6JK74CqMAsQ46LUhKu7AqX274TDuaAdoDwpbBh", + "realmAuthorityAfter": "9EEcpw6JK74CqMAsQ46LUhKu7AqX274TDuaAdoDwpbBh", + "metadataUpdateAuthorityAfter": "SHiJ58FvbJQJnPEUa5mZNVpWd1ym9CkjjPgeHYzUJmh", + "txTransferAuthorities": "4QrYB8xne3MSrPjJiUBFy2xrukmyV18qBzSmcWaBr3LRdbUetxbgdYJvbw18mjgweLYKML39VpGTMhJW2GMZqsfi", + "txSetRealmAuthority": "5GgbwX7YYveKumXpFDBytJbfsqiPhhzvFaxNaByqYbXtP8WNgjw24uWvYPTGp2gCukhdMbLyXi3nieyNrAwuz1V6", + "txSetMetadataUpdateAuthority": "a3wNDpx743sTZB2ckH2w6yIuj3uW7aivTIR2OSwUgFkv8jYvmhaH4rV0KuHU5/gPzTJOyDBDOyiUaqXcDfGKCw==" +} \ No newline at end of file diff --git a/shine-solana/shine/create-SHiNE-DAO/nft-flow.config.env.example b/shine-solana/shine/create-SHiNE-DAO/nft-flow.config.env.example new file mode 100644 index 0000000..984e625 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/nft-flow.config.env.example @@ -0,0 +1,24 @@ +# create-SHiNE-DAO / конфиг NFT flow через уже созданное DAO + +# devnet | mainnet-beta | custom rpc url +CLUSTER="devnet" + +SPL_GOVERNANCE_PROGRAM_ID="GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw" + +# Подставить из отчета создания DAO +REALM="REPLACE_REALM" +GOVERNANCE="REPLACE_GOVERNANCE" +GOVERNING_MINT="REPLACE_GOVERNING_MINT" + +# Основной голосующий и второй голосующий +MAIN_KEYPAIR="./local/main.json" +VOTER2_KEYPAIR="./local/voter2.json" + +# Metadata целевого NFT, который будет mint/burn через DAO +NFT_NAME="SHiNE Membership NFT" +NFT_SYMBOL="SHINE-NFT" +NFT_METADATA_URI="https://arweave.net/CHANGE_ME_NFT_METADATA_JSON" +NFT_IMAGE_URL="https://arweave.net/CHANGE_ME_NFT_IMAGE" + +# Куда складывать отчеты локально +RUNS_DIR="./local/runs" diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/01_check_dao_config.sh b/shine-solana/shine/create-SHiNE-DAO/scripts/01_check_dao_config.sh new file mode 100755 index 0000000..ea3bf78 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/01_check_dao_config.sh @@ -0,0 +1,104 @@ +#!/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:-}" || -z "${DAO_GOV_TOKEN_MINT:-}" ]]; then + echo "Ошибка: обязательные поля конфига пустые" + exit 1 +fi + +if [[ ! -f "$DAO_ISSUER_KEYPAIR" ]]; then + echo "Ошибка: keypair не найден: $DAO_ISSUER_KEYPAIR" + exit 1 +fi + +if ! [[ "${DAO_VOTING_TIME_SEC}" =~ ^[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)" +MINT_PUBKEY="$DAO_GOV_TOKEN_MINT" + +if ! node -e "const { PublicKey } = require('@solana/web3.js'); new PublicKey(process.argv[1])" "$MINT_PUBKEY" >/dev/null 2>&1; then + echo "Ошибка: DAO_GOV_TOKEN_MINT не является валидным pubkey" + exit 1 +fi + +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 +)" + +REALM_EXISTS="no" +if solana account "$REALM_PDA" --url "$DAO_CLUSTER" >/dev/null 2>&1; then + REALM_EXISTS="yes" +fi + +MINT_EXISTS="no" +if solana account "$MINT_PUBKEY" --url "$DAO_CLUSTER" >/dev/null 2>&1; then + MINT_EXISTS="yes" +fi + +cat < assertRequired(cfg, k)); + + const cluster = cfg.DAO_CLUSTER; + const connection = new Connection(clusterUrl(cluster), "confirmed"); + const issuer = loadKeypair(path.resolve(cfg.DAO_ISSUER_KEYPAIR)); + const mintKeypair = cfg.DAO_GOV_MINT_KEYPAIR + ? loadKeypair(path.resolve(cfg.DAO_GOV_MINT_KEYPAIR)) + : Keypair.generate(); + const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID); + + const startBalance = await connection.getBalance(issuer.publicKey, "confirmed"); + console.log("============================================================"); + console.log("CREATE SHiNE GOVERNANCE MINT"); + console.log("------------------------------------------------------------"); + console.log("Сеть: ", cluster); + console.log("Mint: ", mintKeypair.publicKey.toBase58()); + console.log("Issuer: ", issuer.publicKey.toBase58()); + console.log("Governance program:", governanceProgramId.toBase58()); + console.log("Баланс до: ", `${lamportsToSol(startBalance)} SOL`); + console.log("Name: ", cfg.DAO_GOV_NFT_NAME); + console.log("Symbol: ", cfg.DAO_GOV_NFT_SYMBOL); + console.log("Metadata URI: ", cfg.DAO_GOV_TOKEN_METADATA_URI); + console.log("Image URL: ", cfg.DAO_GOV_TOKEN_IMAGE_URL); + console.log("============================================================"); + + const mintLen = getMintLen([]); + const mintRent = await connection.getMinimumBalanceForRentExemption(mintLen); + 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) + ); + const sigMint = await sendAndConfirmTransaction(connection, txMint, [issuer, mintKeypair], { + commitment: "confirmed", + }); + + const sigMetadata = await attachGovernanceMetadata(cfg, cluster, issuer, mintKeypair.publicKey, mintKeypair); + + const report = { + createdAt: new Date().toISOString(), + cluster, + mint: mintKeypair.publicKey.toBase58(), + issuer: issuer.publicKey.toBase58(), + mintAuthority: issuer.publicKey.toBase58(), + freezeAuthority: issuer.publicKey.toBase58(), + metadataUpdateAuthority: issuer.publicKey.toBase58(), + governanceProgramId: governanceProgramId.toBase58(), + metadataUri: cfg.DAO_GOV_TOKEN_METADATA_URI, + imageUrl: cfg.DAO_GOV_TOKEN_IMAGE_URL, + txMint: sigMint, + txMetadata: sigMetadata, + }; + const runsDir = path.resolve(__dirname, "..", "local", "runs"); + ensureDir(runsDir); + const reportPath = path.join(runsDir, `${nowStamp()}_governance_mint.json`); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + + console.log("Готово. Governance mint создан."); + console.log("mint:", mintKeypair.publicKey.toBase58()); + console.log("report:", reportPath); +} + +main().catch((e) => { + console.error("CreateMint error:", e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/01a_mint_governance_token.js b/shine-solana/shine/create-SHiNE-DAO/scripts/01a_mint_governance_token.js new file mode 100644 index 0000000..d8a52fa --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/01a_mint_governance_token.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +"use strict"; + +const path = require("path"); +const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js"); +const { + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, + createAssociatedTokenAccountIdempotentInstruction, + createMintToInstruction, + getMint, +} = require("@solana/spl-token"); +const { clusterUrl, loadKeypair, parseEnvConfig, resolveConfigPath } = require("./js_common"); + +async function main() { + const targetWallet = process.argv[3]; + const amountRaw = process.argv[4] || "1"; + if (!targetWallet) { + throw new Error("Usage: node 01a_mint_governance_token.js [amount]"); + } + + const cfg = parseEnvConfig(resolveConfigPath(process.argv[2], "dao.config.env")); + if (!cfg.DAO_GOV_TOKEN_MINT) throw new Error("В конфиге пустой DAO_GOV_TOKEN_MINT"); + if (!cfg.DAO_ISSUER_KEYPAIR) throw new Error("В конфиге пустой DAO_ISSUER_KEYPAIR"); + + const conn = new Connection(clusterUrl(cfg.DAO_CLUSTER || "devnet"), "confirmed"); + const issuer = loadKeypair(path.resolve(cfg.DAO_ISSUER_KEYPAIR)); + const mint = new PublicKey(cfg.DAO_GOV_TOKEN_MINT); + const target = new PublicKey(targetWallet); + const amount = BigInt(amountRaw); + + const mintInfo = await getMint(conn, mint, "confirmed", TOKEN_PROGRAM_ID); + if (mintInfo.decimals !== 0) { + throw new Error(`Ожидался governance mint с decimals=0, а получено ${mintInfo.decimals}`); + } + + const ata = getAssociatedTokenAddressSync(mint, target, false, TOKEN_PROGRAM_ID); + const tx = new Transaction().add( + createAssociatedTokenAccountIdempotentInstruction( + issuer.publicKey, + ata, + target, + mint, + TOKEN_PROGRAM_ID + ), + createMintToInstruction( + mint, + ata, + issuer.publicKey, + amount, + [], + TOKEN_PROGRAM_ID + ) + ); + + const sig = await sendAndConfirmTransaction(conn, tx, [issuer], { commitment: "confirmed" }); + + console.log("Mint done."); + console.log("target:", target.toBase58()); + console.log("ata:", ata.toBase58()); + console.log("amount:", amount.toString()); + console.log("signature:", sig); +} + +main().catch((e) => { + console.error("MintGovernanceToken error:", e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/02_create_dao_with_governance_nfts.js b/shine-solana/shine/create-SHiNE-DAO/scripts/02_create_dao_with_governance_nfts.js new file mode 100644 index 0000000..b28f258 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/02_create_dao_with_governance_nfts.js @@ -0,0 +1,224 @@ +#!/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, + PublicKey, + Transaction, + sendAndConfirmTransaction, +} = require("@solana/web3.js"); +const { + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, + getAccount, +} = require("@solana/spl-token"); +const { + MintMaxVoteWeightSource, + VoteThreshold, + VoteThresholdType, + VoteTipping, + GovernanceConfig, + PROGRAM_VERSION_V3, + GoverningTokenConfigAccountArgs, + GoverningTokenType, + withCreateRealm, + withCreateTokenOwnerRecord, + withCreateGovernance, + withCreateNativeTreasury, +} = require("@solana/spl-governance"); +const { clusterUrl, ensureDir, loadKeypair, parseEnvConfig, resolveConfigPath, nowStamp } = require("./js_common"); + +function lamportsToSol(lamports) { + return Number(lamports) / 1_000_000_000; +} + +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"; +} + +async function main() { + const configPath = resolveConfigPath(process.argv[2], "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_ISSUER_KEYPAIR", + "SPL_GOVERNANCE_PROGRAM_ID", + "DAO_GOV_TOKEN_MINT", + ].forEach((k) => { + if (!cfg[k]) throw new Error(`В конфиге отсутствует обязательный параметр: ${k}`); + }); + + const cluster = cfg.DAO_CLUSTER; + const connection = new Connection(clusterUrl(cluster), "confirmed"); + const issuer = loadKeypair(path.resolve(cfg.DAO_ISSUER_KEYPAIR)); + const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID); + const mint = new PublicKey(cfg.DAO_GOV_TOKEN_MINT); + + const mintInfoBefore = await connection.getParsedAccountInfo(mint, "confirmed"); + if (!mintInfoBefore.value) throw new Error(`Governing mint не найден: ${mint.toBase58()}`); + const mintAccountBefore = await connection.getAccountInfo(mint, "confirmed"); + if (!mintAccountBefore || !mintAccountBefore.owner.equals(TOKEN_PROGRAM_ID)) { + throw new Error(`Mint должен быть классическим SPL Token mint: ${mint.toBase58()}`); + } + + const mintParsed = mintInfoBefore.value.data.parsed.info; + const mintAuthorityBefore = mintParsed.mintAuthority || null; + const freezeAuthorityBefore = mintParsed.freezeAuthority || null; + const issuerAta = getAssociatedTokenAddressSync(mint, issuer.publicKey, false, TOKEN_PROGRAM_ID); + const issuerAtaInfo = await getAccount(connection, issuerAta, "confirmed").catch(() => null); + + const [realmPda] = PublicKey.findProgramAddressSync( + [Buffer.from("governance"), Buffer.from(cfg.DAO_REALM_NAME, "utf8")], + governanceProgramId + ); + if ((await connection.getAccountInfo(realmPda)) !== null) { + throw new Error(`Realm уже существует: ${realmPda.toBase58()}`); + } + + const startBalance = await connection.getBalance(issuer.publicKey, "confirmed"); + console.log("============================================================"); + console.log("CREATE SHiNE DAO FROM EXISTING GOVERNANCE MINT"); + console.log("------------------------------------------------------------"); + console.log("Сеть: ", cluster); + console.log("Realm name: ", cfg.DAO_REALM_NAME); + console.log("Realm PDA: ", realmPda.toBase58()); + console.log("Issuer: ", issuer.publicKey.toBase58()); + console.log("Governance mint: ", mint.toBase58()); + console.log("Issuer ATA: ", issuerAta.toBase58()); + console.log("Issuer ATA есть: ", issuerAtaInfo ? "yes" : "no"); + console.log("Issuer ATA amount:", issuerAtaInfo ? issuerAtaInfo.amount.toString() : "0"); + console.log("Баланс до: ", `${lamportsToSol(startBalance)} SOL`); + console.log("Mint authority: ", mintAuthorityBefore || "null"); + console.log("Freeze authority:", freezeAuthorityBefore || "null"); + console.log("============================================================"); + + if (!(await askYes())) { + console.log("Отменено."); + return; + } + + const ixRealm = []; + const communityTokenConfig = new GoverningTokenConfigAccountArgs({ + voterWeightAddin: undefined, + maxVoterWeightAddin: undefined, + tokenType: GoverningTokenType.Liquid, + }); + const realmPk = await withCreateRealm( + ixRealm, + governanceProgramId, + PROGRAM_VERSION_V3, + cfg.DAO_REALM_NAME, + issuer.publicKey, + mint, + 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 ixTokenOwnerRecord = []; + const tokenOwnerRecordPk = await withCreateTokenOwnerRecord( + ixTokenOwnerRecord, + governanceProgramId, + PROGRAM_VERSION_V3, + realmPk, + issuer.publicKey, + mint, + issuer.publicKey + ); + const sigTokenOwnerRecord = await sendAndConfirmTransaction( + connection, + new Transaction().add(...ixTokenOwnerRecord), + [issuer], + { commitment: "confirmed" } + ); + + const governanceConfig = new GovernanceConfig({ + communityVoteThreshold: new VoteThreshold({ type: VoteThresholdType.YesVotePercentage, value: Number(cfg.DAO_APPROVAL_THRESHOLD_PERCENT || 51) }), + minCommunityTokensToCreateProposal: new BN(1), + minInstructionHoldUpTime: 0, + baseVotingTime: Number(cfg.DAO_VOTING_TIME_SEC || 86400), + 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, + PROGRAM_VERSION_V3, + realmPk, + realmPk, + governanceConfig, + tokenOwnerRecordPk, + issuer.publicKey, + issuer.publicKey + ); + const treasuryPk = await withCreateNativeTreasury(ixGov, governanceProgramId, PROGRAM_VERSION_V3, governancePk, issuer.publicKey); + const sigGov = await sendAndConfirmTransaction(connection, new Transaction().add(...ixGov), [issuer], { + commitment: "confirmed", + }); + + const report = { + createdAt: new Date().toISOString(), + cluster, + realmName: cfg.DAO_REALM_NAME, + governanceProgramId: governanceProgramId.toBase58(), + governingMint: mint.toBase58(), + issuer: issuer.publicKey.toBase58(), + issuerAta: issuerAta.toBase58(), + realm: realmPk.toBase58(), + governance: governancePk.toBase58(), + nativeTreasury: treasuryPk.toBase58(), + tokenOwnerRecord: tokenOwnerRecordPk.toBase58(), + mintAuthorityBefore, + freezeAuthorityBefore, + txRealm: sigRealm, + txTokenOwnerRecord: sigTokenOwnerRecord, + txGovernanceTreasury: sigGov, + communityTokenType: "Liquid", + }; + const runsDir = path.resolve(__dirname, "..", "local", "runs"); + ensureDir(runsDir); + const reportPath = path.join(runsDir, `${nowStamp()}_create_dao_from_existing_mint.json`); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + + console.log("DAO created."); + console.log("communityMint:", mint.toBase58()); + console.log("realm:", realmPk.toBase58()); + console.log("governance:", governancePk.toBase58()); + console.log("nativeTreasury:", treasuryPk.toBase58()); + console.log("tokenOwnerRecord:", tokenOwnerRecordPk.toBase58()); + console.log("spentSol:", "see report via tx fees + rent"); + console.log("report:", reportPath); +} + +main().catch((e) => { + console.error("CreateDAO error:", e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/03_transfer_dao_authorities.js b/shine-solana/shine/create-SHiNE-DAO/scripts/03_transfer_dao_authorities.js new file mode 100644 index 0000000..a98fe6f --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/03_transfer_dao_authorities.js @@ -0,0 +1,268 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const readline = require("readline"); +const { + Connection, + PublicKey, + Transaction, + sendAndConfirmTransaction, +} = require("@solana/web3.js"); +const { + TOKEN_PROGRAM_ID, + AuthorityType, + createSetAuthorityInstruction, +} = require("@solana/spl-token"); +const { + PROGRAM_VERSION_V3, + getRealm, + withSetRealmAuthority, + SetRealmAuthorityAction, +} = require("@solana/spl-governance"); +const { createUmi } = require("@metaplex-foundation/umi-bundle-defaults"); +const { + createSignerFromKeypair, + signerIdentity, + none, + some, +} = require("@metaplex-foundation/umi"); +const { fromWeb3JsKeypair, fromWeb3JsPublicKey } = require("@metaplex-foundation/umi-web3js-adapters"); +const { + mplTokenMetadata, + fetchMetadataFromSeeds, + updateV1, +} = require("@metaplex-foundation/mpl-token-metadata"); +const { + clusterUrl, + ensureDir, + loadKeypair, + nowStamp, + parseEnvConfig, + resolveConfigPath, +} = require("./js_common"); + +function lamportsToSol(lamports) { + return Number(lamports) / 1_000_000_000; +} + +async function askYes() { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => + rl.question("Введите YES для передачи authority на DAO: ", resolve) + ); + rl.close(); + return answer.trim() === "YES"; +} + +async function fetchMetadataState(cluster, issuer, mint) { + const umi = createUmi(clusterUrl(cluster)); + const issuerSigner = createSignerFromKeypair(umi, fromWeb3JsKeypair(issuer)); + umi.use(signerIdentity(issuerSigner)); + umi.use(mplTokenMetadata()); + + const metadata = await fetchMetadataFromSeeds(umi, { mint: fromWeb3JsPublicKey(mint) }); + return { + umi, + issuerSigner, + metadata, + }; +} + +async function fetchMetadataUpdateAuthority(cluster, mint) { + const umi = createUmi(clusterUrl(cluster)); + umi.use(mplTokenMetadata()); + const metadata = await fetchMetadataFromSeeds(umi, { mint: fromWeb3JsPublicKey(mint) }); + return metadata.updateAuthority.toString(); +} + +async function main() { + const configPath = resolveConfigPath(process.argv[2], "dao.config.env"); + if (!fs.existsSync(configPath)) throw new Error(`Конфиг не найден: ${configPath}`); + + const cfg = parseEnvConfig(configPath); + [ + "DAO_CLUSTER", + "DAO_REALM_NAME", + "DAO_ISSUER_KEYPAIR", + "SPL_GOVERNANCE_PROGRAM_ID", + "DAO_GOV_TOKEN_MINT", + ].forEach((k) => { + if (!cfg[k]) throw new Error(`В конфиге отсутствует обязательный параметр: ${k}`); + }); + + const cluster = cfg.DAO_CLUSTER; + const connection = new Connection(clusterUrl(cluster), "confirmed"); + const issuer = loadKeypair(path.resolve(cfg.DAO_ISSUER_KEYPAIR)); + const governanceProgramId = new PublicKey(cfg.SPL_GOVERNANCE_PROGRAM_ID); + const mint = new PublicKey(cfg.DAO_GOV_TOKEN_MINT); + + const [realmPk] = PublicKey.findProgramAddressSync( + [Buffer.from("governance"), Buffer.from(cfg.DAO_REALM_NAME, "utf8")], + governanceProgramId + ); + const [governancePk] = PublicKey.findProgramAddressSync( + [Buffer.from("account-governance"), realmPk.toBuffer(), realmPk.toBuffer()], + governanceProgramId + ); + + const realmInfo = await connection.getAccountInfo(realmPk, "confirmed"); + if (!realmInfo) throw new Error(`Realm не найден: ${realmPk.toBase58()}. Сначала выполните шаг 2.`); + + const governanceInfo = await connection.getAccountInfo(governancePk, "confirmed"); + if (!governanceInfo) { + throw new Error(`Governance PDA не найден: ${governancePk.toBase58()}. Сначала выполните шаг 2.`); + } + const realmBefore = await getRealm(connection, realmPk); + const realmAuthorityBefore = realmBefore.account.authority?.toBase58?.() || null; + if (realmAuthorityBefore !== issuer.publicKey.toBase58()) { + throw new Error(`Realm authority уже не у issuer: ${realmAuthorityBefore}`); + } + + const mintInfoBefore = await connection.getParsedAccountInfo(mint, "confirmed"); + if (!mintInfoBefore.value) throw new Error(`Mint не найден: ${mint.toBase58()}`); + const mintParsedBefore = mintInfoBefore.value.data.parsed.info; + const mintAuthorityBefore = mintParsedBefore.mintAuthority || null; + const freezeAuthorityBefore = mintParsedBefore.freezeAuthority || null; + + if (mintAuthorityBefore && mintAuthorityBefore !== issuer.publicKey.toBase58()) { + throw new Error(`Mint authority уже не у issuer: ${mintAuthorityBefore}`); + } + if (freezeAuthorityBefore && freezeAuthorityBefore !== issuer.publicKey.toBase58()) { + throw new Error(`Freeze authority уже не у issuer: ${freezeAuthorityBefore}`); + } + + const { umi, metadata, issuerSigner } = await fetchMetadataState(cluster, issuer, mint); + const metadataUpdateAuthorityBefore = metadata.updateAuthority.toString(); + if (metadataUpdateAuthorityBefore !== issuer.publicKey.toBase58()) { + throw new Error(`Metadata update authority уже не у issuer: ${metadataUpdateAuthorityBefore}`); + } + + const startBalance = await connection.getBalance(issuer.publicKey, "confirmed"); + console.log("============================================================"); + console.log("TRANSFER DAO AUTHORITIES"); + console.log("------------------------------------------------------------"); + console.log("Сеть: ", cluster); + console.log("Realm: ", realmPk.toBase58()); + console.log("Governance PDA: ", governancePk.toBase58()); + console.log("Issuer: ", issuer.publicKey.toBase58()); + console.log("Governance mint: ", mint.toBase58()); + console.log("Баланс до: ", `${lamportsToSol(startBalance)} SOL`); + console.log("Mint authority до: ", mintAuthorityBefore || "null"); + console.log("Freeze authority до: ", freezeAuthorityBefore || "null"); + console.log("Realm authority до: ", realmAuthorityBefore || "null"); + console.log("Metadata authority до: ", metadataUpdateAuthorityBefore); + console.log("============================================================"); + + if (!(await askYes())) { + console.log("Отменено."); + return; + } + + const ixTransferAuthorities = [ + createSetAuthorityInstruction( + mint, + issuer.publicKey, + AuthorityType.MintTokens, + governancePk, + [], + TOKEN_PROGRAM_ID + ), + createSetAuthorityInstruction( + mint, + issuer.publicKey, + AuthorityType.FreezeAccount, + governancePk, + [], + TOKEN_PROGRAM_ID + ), + ]; + const sigTransferAuthorities = await sendAndConfirmTransaction( + connection, + new Transaction().add(...ixTransferAuthorities), + [issuer], + { commitment: "confirmed" } + ); + + const ixSetRealmAuthority = []; + withSetRealmAuthority( + ixSetRealmAuthority, + governanceProgramId, + PROGRAM_VERSION_V3, + realmPk, + issuer.publicKey, + governancePk, + SetRealmAuthorityAction.SetChecked + ); + const sigSetRealmAuthority = await sendAndConfirmTransaction( + connection, + new Transaction().add(...ixSetRealmAuthority), + [issuer], + { commitment: "confirmed" } + ); + + const metadataTx = await updateV1(umi, { + authority: issuerSigner, + mint: fromWeb3JsPublicKey(mint), + newUpdateAuthority: some(fromWeb3JsPublicKey(governancePk)), + data: none(), + primarySaleHappened: none(), + isMutable: none(), + collection: { __kind: "None" }, + collectionDetails: { __kind: "None" }, + uses: { __kind: "None" }, + ruleSet: { __kind: "None" }, + authorizationData: none(), + }).sendAndConfirm(umi); + const sigMetadataAuthority = Buffer.from(metadataTx.signature).toString("base64"); + + const mintInfoAfter = await connection.getParsedAccountInfo(mint, "confirmed"); + const mintParsedAfter = mintInfoAfter.value.data.parsed.info; + const mintAuthorityAfter = mintParsedAfter.mintAuthority || null; + const freezeAuthorityAfter = mintParsedAfter.freezeAuthority || null; + const realmAfter = await getRealm(connection, realmPk); + const realmAuthorityAfter = realmAfter.account.authority?.toBase58?.() || null; + + const metadataUpdateAuthorityAfter = await fetchMetadataUpdateAuthority(cluster, mint); + + const report = { + createdAt: new Date().toISOString(), + cluster, + realmName: cfg.DAO_REALM_NAME, + governanceProgramId: governanceProgramId.toBase58(), + issuer: issuer.publicKey.toBase58(), + governingMint: mint.toBase58(), + realm: realmPk.toBase58(), + governance: governancePk.toBase58(), + mintAuthorityBefore, + freezeAuthorityBefore, + metadataUpdateAuthorityBefore, + realmAuthorityBefore, + mintAuthorityAfter, + freezeAuthorityAfter, + realmAuthorityAfter, + metadataUpdateAuthorityAfter, + txTransferAuthorities: sigTransferAuthorities, + txSetRealmAuthority: sigSetRealmAuthority, + txSetMetadataUpdateAuthority: sigMetadataAuthority, + }; + const runsDir = path.resolve(__dirname, "..", "local", "runs"); + ensureDir(runsDir); + const reportPath = path.join(runsDir, `${nowStamp()}_transfer_dao_authorities.json`); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + + console.log("Все authority переданы на DAO."); + console.log("realm:", realmPk.toBase58()); + console.log("governance:", governancePk.toBase58()); + console.log("mintAuthority после:", mintAuthorityAfter || "null"); + console.log("freezeAuthority после:", freezeAuthorityAfter || "null"); + console.log("realmAuthority после:", realmAuthorityAfter || "null"); + console.log("metadataAuthority после:", metadataUpdateAuthorityAfter); + console.log("report:", reportPath); +} + +main().catch((e) => { + console.error("TransferAuthorities error:", e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/04_prepare_second_voter.js b/shine-solana/shine/create-SHiNE-DAO/scripts/04_prepare_second_voter.js new file mode 100644 index 0000000..6559ae8 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/04_prepare_second_voter.js @@ -0,0 +1,44 @@ +#!/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 { PublicKey, clusterUrl, loadKeypair, parseEnvConfig, resolveConfigPath } = require("./js_common"); + +async function main() { + const cfg = parseEnvConfig(resolveConfigPath(process.argv[2], "nft-flow.config.env")); + 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 mintTx = new Transaction().add( + createAssociatedTokenAccountIdempotentInstruction(main.publicKey, ata, voter2.publicKey, mint, TOKEN_PROGRAM_ID), + createMintToInstruction(mint, ata, main.publicKey, 1n, [], TOKEN_PROGRAM_ID) + ); + const sigMint = await sendAndConfirmTransaction(conn, mintTx, [main], { commitment: "confirmed" }); + + const tor = await getTokenOwnerRecordAddress(govPid, realm, mint, voter2.publicKey); + const ai = await conn.getAccountInfo(tor, "confirmed"); + let sigDeposit = "already_exists"; + if (!ai) { + const ix = []; + await withDepositGoverningTokens(ix, govPid, PROGRAM_VERSION_V3, realm, ata, mint, voter2.publicKey, main.publicKey, voter2.publicKey, new BN(1), true); + sigDeposit = await sendAndConfirmTransaction(conn, new Transaction().add(...ix), [main, voter2], { commitment: "confirmed" }); + } + + console.log("Второй голосующий подготовлен."); + console.log("mintTx:", sigMint); + console.log("depositTx:", sigDeposit); +} + +main().catch((e) => { + console.error(e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/05_create_empty_nft_template_with_metadata.js b/shine-solana/shine/create-SHiNE-DAO/scripts/05_create_empty_nft_template_with_metadata.js new file mode 100644 index 0000000..297f8d4 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/05_create_empty_nft_template_with_metadata.js @@ -0,0 +1,117 @@ +#!/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 { 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"); +const { PublicKey, assertRequired, clusterUrl, ensureArweaveUri, ensureDir, loadKeypair, nowStamp, parseEnvConfig, resolveConfigPath } = require("./js_common"); + +async function attachNftMetadata(cfg, cluster, payer, mintKeypair) { + ensureArweaveUri("NFT_METADATA_URI", cfg.NFT_METADATA_URI); + ensureArweaveUri("NFT_IMAGE_URL", cfg.NFT_IMAGE_URL); + + const umi = createUmi(clusterUrl(cluster)); + const payerSigner = createSignerFromKeypair(umi, fromWeb3JsKeypair(payer)); + const mintSigner = createSignerFromKeypair(umi, fromWeb3JsKeypair(mintKeypair)); + umi.use(signerIdentity(payerSigner)); + umi.use(mplTokenMetadata()); + + const builder = createV1(umi, { + mint: mintSigner, + authority: payerSigner, + payer: payerSigner, + updateAuthority: payerSigner, + name: cfg.NFT_NAME, + symbol: cfg.NFT_SYMBOL, + uri: cfg.NFT_METADATA_URI, + sellerFeeBasisPoints: percentAmount(0), + tokenStandard: TokenStandard.NonFungible, + decimals: some(0), + creators: none(), + collection: none(), + uses: none(), + collectionDetails: none(), + ruleSet: none(), + printSupply: none(), + primarySaleHappened: false, + isMutable: true, + isCollection: false, + splTokenProgram: fromWeb3JsPublicKey(TOKEN_2022_PROGRAM_ID), + }); + + const result = await builder.sendAndConfirm(umi); + return Buffer.from(result.signature).toString("base64"); +} + +async function main() { + const cfg = parseEnvConfig(resolveConfigPath(process.argv[2], "nft-flow.config.env")); + ["CLUSTER", "MAIN_KEYPAIR", "GOVERNANCE", "NFT_NAME", "NFT_SYMBOL", "NFT_METADATA_URI", "NFT_IMAGE_URL"].forEach((k) => assertRequired(cfg, k)); + + 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 metadataSig = await attachNftMetadata(cfg, cfg.CLUSTER, main, mint); + + const runsDir = path.resolve(__dirname, "..", cfg.RUNS_DIR || "./local/runs"); + ensureDir(runsDir); + const report = { + createdAt: new Date().toISOString(), + mint: mint.publicKey.toBase58(), + governance: governance.toBase58(), + metadataUri: cfg.NFT_METADATA_URI, + imageUrl: cfg.NFT_IMAGE_URL, + txMintTemplate: sig, + txMetadata: metadataSig, + }; + const reportPath = path.join(runsDir, `${nowStamp()}_nft_template.json`); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + + console.log("NFT mint-шаблон с metadata создан."); + console.log("mint:", report.mint); + console.log("report:", reportPath); +} + +main().catch((e) => { + console.error(e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/06_propose_vote_mint_nft.js b/shine-solana/shine/create-SHiNE-DAO/scripts/06_propose_vote_mint_nft.js new file mode 100644 index 0000000..58f0220 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/06_propose_vote_mint_nft.js @@ -0,0 +1,107 @@ +#!/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, createAssociatedTokenAccountIdempotentInstruction, createMintToInstruction } = require("@solana/spl-token"); +const { + PROGRAM_VERSION_V3, + Vote, + YesNoVote, + VoteType, + withCreateProposal, + withInsertTransaction, + withSignOffProposal, + withCastVote, + getTokenOwnerRecordAddress, + getProposalTransactionAddress, +} = require("@solana/spl-governance"); +const { clusterUrl, ensureDir, loadKeypair, nowStamp, parseEnvConfig, resolveConfigPath, 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 06_propose_vote_mint_nft.js "); + + const cfg = parseEnvConfig(resolveConfigPath(process.argv[2], "nft-flow.config.env")); + 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 ixCreate = []; + const proposal = await withCreateProposal( + ixCreate, + govPid, + PROGRAM_VERSION_V3, + realm, + governance, + mainTor, + `Mint NFT to ${target.toBase58().slice(0, 8)}`, + cfg.NFT_METADATA_URI || "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 = [ + createAssociatedTokenAccountIdempotentInstruction(main.publicKey, targetAta, target, nftMint, TOKEN_2022_PROGRAM_ID), + createMintToInstruction(nftMint, targetAta, governance, 1n, [], TOKEN_2022_PROGRAM_ID), + ].map(toInstructionData); + const ixInsert = []; + const proposalTx = await withInsertTransaction(ixInsert, govPid, PROGRAM_VERSION_V3, governance, proposal, mainTor, main.publicKey, 0, 0, 0, mintIx, 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 || "./local/runs"); + ensureDir(runs); + 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 reportPath = path.join(runs, `${nowStamp()}_proposal_mint_${target.toBase58().slice(0, 8)}.json`); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + + console.log("Mint proposal создан и проголосован."); + console.log("proposal:", report.proposal); + console.log("proposalTransaction:", report.proposalTransaction); + console.log("report:", reportPath); +} + +main().catch((e) => { + console.error(e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/07_execute_mint_nft.js b/shine-solana/shine/create-SHiNE-DAO/scripts/07_execute_mint_nft.js new file mode 100644 index 0000000..a556b9c --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/07_execute_mint_nft.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +"use strict"; + +const path = require("path"); +const { Connection, PublicKey, Transaction, sendAndConfirmTransaction } = require("@solana/web3.js"); +const { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, createMintToInstruction } = require("@solana/spl-token"); +const { PROGRAM_VERSION_V3, withExecuteTransaction } = require("@solana/spl-governance"); +const { clusterUrl, loadKeypair, parseEnvConfig, resolveConfigPath, toInstructionData } = require("./js_common"); + +async function main() { + const cfg = parseEnvConfig(resolveConfigPath(process.argv[2], "nft-flow.config.env")); + const proposal = new PublicKey(process.argv[3]); + const proposalTx = new PublicKey(process.argv[4]); + const target = new PublicKey(process.argv[5]); + const nftMint = new PublicKey(process.argv[6]); + if (!process.argv[6]) throw new Error("Usage: node 07_execute_mint_nft.js "); + + 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 mintIx = [ + createAssociatedTokenAccountIdempotentInstruction(main.publicKey, targetAta, target, nftMint, TOKEN_2022_PROGRAM_ID), + 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("Mint execute done:", sig); +} + +main().catch((e) => { + console.error(e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/08_propose_vote_burn_nft.js b/shine-solana/shine/create-SHiNE-DAO/scripts/08_propose_vote_burn_nft.js new file mode 100644 index 0000000..8f736ce --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/08_propose_vote_burn_nft.js @@ -0,0 +1,101 @@ +#!/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 { clusterUrl, ensureDir, loadKeypair, nowStamp, parseEnvConfig, resolveConfigPath, toInstructionData } = require("./js_common"); + +async function main() { + const currentOwnerWallet = process.argv[3]; + const nftMintStr = process.argv[4]; + if (!currentOwnerWallet || !nftMintStr) throw new Error("Usage: node 08_propose_vote_burn_nft.js "); + + const cfg = parseEnvConfig(resolveConfigPath(process.argv[2], "nft-flow.config.env")); + 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 currentOwner = new PublicKey(currentOwnerWallet); + const mainTor = await getTokenOwnerRecordAddress(govPid, realm, governingMint, main.publicKey); + const sourceAta = getAssociatedTokenAddressSync(nftMint, currentOwner, 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)}`, + cfg.NFT_METADATA_URI || "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 || "./local/runs"); + ensureDir(runs); + const report = { + type: "burn_nft", + realm: realm.toBase58(), + governance: governance.toBase58(), + proposal: proposal.toBase58(), + proposalTransaction: proposalTx.toBase58(), + nftMint: nftMint.toBase58(), + currentOwnerWallet: currentOwner.toBase58(), + sourceAta: sourceAta.toBase58(), + txCreate, + txInsert, + txSign, + txVote1, + }; + const reportPath = path.join(runs, `${nowStamp()}_proposal_burn_${currentOwner.toBase58().slice(0, 8)}.json`); + fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); + + console.log("Burn proposal создан и проголосован."); + console.log("proposal:", report.proposal); + console.log("proposalTransaction:", report.proposalTransaction); + console.log("sourceAta:", report.sourceAta); + console.log("report:", reportPath); +} + +main().catch((e) => { + console.error(e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/09_execute_burn_nft.js b/shine-solana/shine/create-SHiNE-DAO/scripts/09_execute_burn_nft.js new file mode 100644 index 0000000..dedc3a6 --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/09_execute_burn_nft.js @@ -0,0 +1,35 @@ +#!/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 { clusterUrl, loadKeypair, parseEnvConfig, resolveConfigPath, toInstructionData } = require("./js_common"); + +async function main() { + const cfg = parseEnvConfig(resolveConfigPath(process.argv[2], "nft-flow.config.env")); + const proposal = new PublicKey(process.argv[3]); + const proposalTx = new PublicKey(process.argv[4]); + const currentOwner = new PublicKey(process.argv[5]); + const nftMint = new PublicKey(process.argv[6]); + if (!process.argv[6]) throw new Error("Usage: node 09_execute_burn_nft.js "); + + 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, currentOwner, 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("Burn execute done:", sig); +} + +main().catch((e) => { + console.error(e?.message || e); + process.exit(1); +}); diff --git a/shine-solana/shine/create-SHiNE-DAO/scripts/js_common.js b/shine-solana/shine/create-SHiNE-DAO/scripts/js_common.js new file mode 100644 index 0000000..ddc39fb --- /dev/null +++ b/shine-solana/shine/create-SHiNE-DAO/scripts/js_common.js @@ -0,0 +1,91 @@ +#!/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 trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + value = value.replace(/\$HOME/g, process.env.HOME || ""); + out[key] = value; + } + return out; +} + +function resolveConfigPath(argvPath, fallbackName) { + return argvPath ? path.resolve(argvPath) : path.resolve(__dirname, "..", fallbackName); +} + +function loadKeypair(filePath) { + const arr = JSON.parse(fs.readFileSync(filePath, "utf8")); + return Keypair.fromSecretKey(Uint8Array.from(arr)); +} + +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 ensureArweaveUri(name, uri) { + if (!uri) throw new Error(`${name} пустой`); + if (!(uri.startsWith("https://arweave.net/") || uri.startsWith("ar://"))) { + throw new Error(`${name} должен указывать на Arweave`); + } +} + +function assertRequired(cfg, key) { + if (!cfg[key]) throw new Error(`В конфиге отсутствует обязательный параметр: ${key}`); +} + +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), + }); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +module.exports = { + PublicKey, + assertRequired, + clusterUrl, + ensureArweaveUri, + ensureDir, + loadKeypair, + nowStamp, + parseEnvConfig, + resolveConfigPath, + toInstructionData, +}; diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md index 6039f04..ea1dc0a 100644 --- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md +++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md @@ -273,7 +273,7 @@ ServerProfileBlock - `address_format_version` — версия формата адреса, сейчас `0`; - `sync_servers_count` максимум `32`; - `server_address` - строковый адрес сервера в соответствии с `address_format_type`; -- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. +- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует серверные данные и пользовательские блокчейны SHiNE. Это server-to-server список партнёров самого серверного узла, а не список серверов доставки личных сообщений для обычного пользователя. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. ## 13. AccessServersBlock @@ -291,7 +291,7 @@ AccessServersBlock - блок может отсутствовать, если серверы доступа не заданы; - список может обновляться при изменении маршрутизации пользователя; -- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; +- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay для конкретного пользователя. Через этот список клиентская и серверная логика SHiNE может маршрутизировать доставку личных сообщений, доступ к сессиям и другие пользовательские операции. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; - точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. ## 14. SessionsBlock diff --git a/shine-solana/shine/doc/programs/shine_login_guard.md b/shine-solana/shine/doc/programs/shine_login_guard.md index 32135df..0ab3a6d 100644 --- a/shine-solana/shine/doc/programs/shine_login_guard.md +++ b/shine-solana/shine/doc/programs/shine_login_guard.md @@ -1,6 +1,6 @@ # Программа `shine_login_guard` -Документ описывает целевое поведение программы классификации логинов SHiNE. +Документ описывает текущее временное поведение программы классификации логинов SHiNE. Задача программы: @@ -12,50 +12,26 @@ `shine_login_guard` — маленькая служебная программа, которую вызывает `shine_users` через CPI до регистрации пользователя. -Она отвечает только на вопрос: +В текущем временном режиме она отвечает только на вопрос: -- можно ли зарегистрировать логин автоматически; -- является ли логин premium; -- является ли логин trademark/company. +- является ли логин допустимым для автоматической регистрации; +- нужно ли отклонить слишком короткий или некорректный логин. + +UI может накладывать дополнительные временные ограничения поверх этой логики, но сама программа про них не знает. ## 2. Program ID Текущий program id: -- `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` +- `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` ## 3. Состояние У программы нет PDA-состояний и persistent storage. -Она опирается на статически сгенерированные словари, встроенные в бинарник на этапе сборки. +Программа полностью stateless и не опирается на словари при классификации. -## 4. Внешние артефакты - -Словари хранятся в: - -- `src/dictionaries/premium/**/*.txt` -- `src/dictionaries/trademarks/**/*.txt` - -На этапе build: - -- все `.txt` файлы рекурсивно собираются; -- слова нормализуются; -- дубль-слова детектятся и логируются как warning; -- итоговые массивы слов вшиваются в generated Rust file. - -## 5. Нормализация словаря - -Слово словаря считается валидным, если: - -- не пустое; -- длина не больше `20`; -- содержит только ASCII-буквы и цифры; -- после `trim()` и `lowercase()` остаётся валидным. - -Символ `_` в словарных словах не допускается. - -## 6. Единственная инструкция +## 4. Единственная инструкция Инструкция: @@ -73,9 +49,9 @@ - аккаунты не требуются -Подпись транзакции для самой классификации не имеет бизнес-смысла. Если вызов идёт через CPI из `shine_users`, программа `shine_login_guard` не требует signer-аккаунтов вообще. +Если вызов идёт через CPI из `shine_users`, программа `shine_login_guard` не требует signer-аккаунтов. -## 7. Выходные классы +## 5. Выходные классы Программа возвращает `u32` через `set_return_data`: @@ -83,7 +59,14 @@ - `1` — `CLASS_PREMIUM` - `2` — `CLASS_TRADEMARK` -## 8. Правила нормализации логина +Сейчас фактически используются только: + +- `CLASS_FREE` +- `CLASS_PREMIUM` + +`CLASS_TRADEMARK` сохранён для совместимости ABI, но временная логика его не возвращает. + +## 6. Правила нормализации логина Перед классификацией логин нормализуется: @@ -97,50 +80,51 @@ - логин считается `CLASS_PREMIUM`. -Это намеренно жёсткое поведение: сомнительный логин не должен автоматически попадать в free-класс. +Это означает, что `shine_users` не пустит такой логин в обычную регистрацию. -## 9. Правила классификации +## 7. Временные правила классификации -Алгоритм: +Текущий временный алгоритм: 1. Нормализовать логин. -2. Попробовать разрезать его на `1..3` слов из словарей. -3. Если среди найденных частей есть trademark-слово, вернуть `CLASS_TRADEMARK`. -4. Если есть только premium-слова, вернуть `CLASS_PREMIUM`. -5. Если словарное совпадение не найдено и длина нормализованного логина `<= 7`, вернуть `CLASS_PREMIUM`. -6. Иначе вернуть `CLASS_FREE`. +2. Если нормализованный логин короче `5` символов, вернуть `CLASS_PREMIUM`. +3. Иначе вернуть `CLASS_FREE`. -## 10. Разбиение на слова +То есть on-chain программа в этом режиме: -Максимум слов в логине: +- пропускает любые валидные логины длиной от `5` символов; +- не использует premium-словари; +- не использует trademark-словари; +- не выполняет словарную сегментацию логина. -- `3` +## 8. Граница ответственности -Алгоритм разбиения: +Важно разделять уровни: -- DFS/backtracking по префиксам строки; -- на каждом шаге берётся префикс длиной `1..20`; -- если префикс найден в premium или trademark словаре, поиск продолжается на остатке строки; -- если найден хотя бы один путь с trademark-словом, итог = `CLASS_TRADEMARK`; -- если найден путь только с premium-словами, итог = `CLASS_PREMIUM`. +1. `shine_login_guard` + - проверяет только базовую валидность и минимальную длину `5`. -## 11. Инварианты +2. UI + - может вводить дополнительные временные ограничения; + - например, пускать логины длиной `5..7` только по временному коду. -Должно быть сохранено при переписи: +3. `shine_users` + - остаётся финальной on-chain точкой регистрации; + - дополнительно проверяет общий формат логина в собственной логике. -- тот же способ нормализации логина; -- та же семантика классов `0/1/2`; -- тот же лимит `MAX_WORDS_PER_LOGIN = 3`; -- тот же fallback `len <= 7 => premium`, если словарь ничего не нашёл; -- тот же порядок приоритета `TRADEMARK > PREMIUM > FREE`. +## 9. Совместимость -## 12. Что не обязано сохраниться при переписи +Сохранено: -Можно менять без изменения бизнес-логики: +- тот же ABI инструкции; +- тот же формат `return_data`; +- те же значения классов `0/1/2`; +- тот же `Program ID`. -- build pipeline словарей; -- структуру хранения слов внутри бинарника; -- способ поиска слов, если сохраняется тот же результат; -- техническую форму возврата, если внешний контракт для `shine_users` останется совместимым. +Изменено относительно полной словарной версии: -Но если меняется именно интерфейс `return_data` или классы, это уже требует обновления `shine_users` и документации. +- нет словарной классификации; +- нет разделения на premium/trademark по содержимому логина; +- короткий порог временно снижен до `5`. + +При возврате к полной модели нужно будет обновить этот документ снова синхронно с кодом. diff --git a/shine-solana/shine/doc/programs/shine_payments.md b/shine-solana/shine/doc/programs/shine_payments.md index 607aa0b..a3542b0 100644 --- a/shine-solana/shine/doc/programs/shine_payments.md +++ b/shine-solana/shine/doc/programs/shine_payments.md @@ -22,7 +22,11 @@ Текущий program id: -- `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` +- `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` + +Текущий зашитый административный кошелек: + +- `DAO_WALLET`: `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq` ## 2. Основная модель diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index 75186e0..f65b07a 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -19,8 +19,10 @@ - создание `user_pda` по логину; - обновление `user_pda` без смены логина и root key; - хранение economy-конфига регистрации; +- хранение PDA продавцов promo-логинов; - взимание комиссии за регистрацию и увеличение лимита; - проверку Ed25519-подписей `root_key` и `blockchain_public_key`; +- проверку promo-подписей продавца для обхода premium/trademark login guard; - проверку связности новой версии записи с предыдущей через `prev_record_hash`. Программа не отвечает за: @@ -33,9 +35,9 @@ ## 2. Program ID и внешние зависимости -Текущий program id devnet/localnet: +Текущий program id: -- `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` +- `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` Внешние зависимости по логике: @@ -46,6 +48,12 @@ - системная программа Solana; - sysvar `instructions`. +Текущие зашитые внешние адреса: + +- `DAO_AUTHORITY`: `aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq` +- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v` +- `shine_login_guard`: `SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh` + ## 3. PDA и seed-правила ### 3.1. Пользовательская PDA @@ -91,7 +99,22 @@ system-переводом. Если бы создание шло строго ч Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и не зависят от баланса аккаунта. -### 3.4. Строгий список аккаунтов (нет «лишних» аккаунтов) +### 3.4. PDA продавца promo-логинов + +PDA продавца строится так: + +- seed prefix: `promo_seller=` +- второй seed: логин продавца в нижнем регистре + +Формула: + +```text +promo_seller_pda = PDA(["promo_seller=", lower(seller_login)], shine_users_program_id) +``` + +В программе может существовать сколько угодно таких PDA, по одному на каждого продавца. + +### 3.5. Строгий список аккаунтов (нет «лишних» аккаунтов) Все инструкции `shine_users` читают строго фиксированный набор аккаунтов и после этого явно требуют, чтобы в переданном списке больше ничего не было @@ -130,17 +153,35 @@ system-переводом. Если бы создание шло строго ч Этот документ описывает именно логику программы, а не байтовую структуру блока. +### 4.3. `promo_seller_pda` + +PDA продавца красивых логинов хранит: + +- `version: u8` +- `remaining_sales: u64` +- `min_login_length: u8` +- `signer_pubkey: [u8; 32]` + +Смысл: + +- `remaining_sales` — сколько ещё красивых логинов продавец может выдать; +- `min_login_length` — минимальная длина логина, которую продавец имеет право раздавать; +- `signer_pubkey` — публичный Ed25519-ключ, которым off-chain подписываются promo-коды. + ## 5. Константы и базовые правила Базовые значения из текущей логики: - seed `user_pda`: `user_login=` - seed economy config: `shine_users_economy_config` +- seed promo seller PDA: `promo_seller=` - стартовый размер `user_pda`: `768` байт +- размер `promo_seller_pda`: `64` байта - `LIMIT_STEP = 10_000` - `START_REGISTRATION_FEE_LAMPORTS = 10_000_000` - `START_LAMPORTS_PER_LIMIT_STEP = 100_000` - `START_BONUS_LIMIT = 100_000` +- префикс promo-сообщения: `shine_promo_v1:` Правила: @@ -230,6 +271,37 @@ On-chain инвариант только один: Если `shine_login_guard` вернул что-то иное или return_data некорректны, это ошибка. +### 7.3. Promo-обход login guard + +При создании пользователя off-chain клиент может использовать опциональный `promo_code`. + +Формат строки: + +```text +1- +``` + +Где: + +- первый символ `1` — версия формата promo-кода; +- `seller_login` — логин продавца, по которому ищется `promo_seller_pda`; +- `signature_base58` — Ed25519-подпись по сообщению: + +```text +shine_promo_v1: +``` + +В сам `create_user_pda` при этом передаётся только `seller_login`, а подпись проверяется по отдельной Ed25519-инструкции в транзакции. + +Если promo-код валиден, то: + +- premium/trademark-проверка через `shine_login_guard` не выполняется; +- базовая синтаксическая проверка логина всё равно остаётся обязательной; +- `remaining_sales` уменьшается на `1`; +- длина логина должна быть не меньше `min_login_length`. + +Если promo-код не передан, обычная CPI-проверка через `shine_login_guard` обязательна. + ## 8. Инструкция `init_users_economy_config` ### Назначение @@ -310,6 +382,7 @@ On-chain инвариант только один: - sysvar `instructions` - `users_economy_config_pda` - `shine_login_guard_program` +- опционально: `promo_seller_pda`, если регистрация идёт по promo-коду ### Входные данные @@ -320,6 +393,7 @@ On-chain инвариант только один: - `additional_limit` - mutable fields записи - `root` signature по unsigned части +- опционально `promo_seller_login` ### Бинарный ABI @@ -332,20 +406,26 @@ On-chain инвариант только один: - additional_limit: u64 LE - fields: UserMutableFieldsV1 - root_signature: [u8; 64] +- optional trailing promo_seller_login: string_u8 ``` ### Обязательные проверки 1. Логин валиден по синтаксису. -2. Логин разрешён `shine_login_guard`. -3. `additional_limit % LIMIT_STEP == 0`. -4. inflow vault совпадает с PDA программы `shine_payments`. -5. `user_pda` вычислена правильно и ещё не существует. -6. Поля блокчейна валидны. -7. Поля server/session/trusted валидны по формату. -8. `last_block_signature` соответствует `LastBlockState`. -9. `root signature` соответствует unsigned части записи. -10. Размер сериализованной записи не превышает допустимый стартовый размер PDA или иные ограничения реализации. +2. Если promo-код не передан, логин разрешён `shine_login_guard`. +3. Если promo-регистрация включена, программа получает `promo_seller_login`, а связанная Ed25519-инструкция должна: + - ссылаться на существующий `promo_seller_pda`; + - проходить Ed25519-проверку через `signer_pubkey` продавца; + - иметь `remaining_sales > 0`; + - удовлетворять правилу `login.len() >= min_login_length`. +4. `additional_limit % LIMIT_STEP == 0`. +5. inflow vault совпадает с PDA программы `shine_payments`. +6. `user_pda` вычислена правильно и ещё не существует. +7. Поля блокчейна валидны. +8. Поля server/session/trusted валидны по формату. +9. `last_block_signature` соответствует `LastBlockState`. +10. `root signature` соответствует unsigned части записи. +11. Размер сериализованной записи не превышает допустимый стартовый размер PDA или иные ограничения реализации. ### Экономика @@ -375,9 +455,52 @@ limit_fee(additional_limit) = (additional_limit / LIMIT_STEP) * lamports_per_lim - создаётся PDA; - в неё записывается полная запись `user_pda`; +- если используется promo-код, в `promo_seller_pda` уменьшается `remaining_sales`; - средства переводятся в inflow vault `shine_payments`. -## 11. Инструкция `update_user_pda` +## 11. Инструкция `upsert_promo_seller` + +### Назначение + +Создать нового продавца promo-логинов или полностью перезаписать настройки уже существующего продавца. + +### Авторизация + +Только `DAO_AUTHORITY`. + +### Аккаунты + +- signer +- `promo_seller_pda` +- system program + +### Входные данные + +- `seller_login` +- `remaining_sales` +- `min_login_length` +- `signer_pubkey` + +### Бинарный ABI + +```text +- tag: u8 = 5 +- seller_login: string_u8 +- remaining_sales: u64 LE +- min_login_length: u8 +- signer_pubkey: [u8; 32] +``` + +### Правила + +- signer должен совпадать с `DAO_AUTHORITY`; +- `seller_login` проходит ту же базовую проверку логина; +- `min_login_length` лежит в диапазоне `1..20`; +- адрес PDA обязан совпадать с `promo_seller=` + `lower(seller_login)`; +- если PDA ещё нет, она создаётся; +- если PDA уже есть, её состояние полностью перезаписывается. + +## 12. Инструкция `update_user_pda` ### Назначение @@ -445,9 +568,9 @@ topup_fee = limit_fee(additional_limit) - root_signature: [u8; 64] ``` -## 12. Ed25519-проверки и порядок инструкций +## 13. Ed25519-проверки и порядок инструкций -В транзакции должны стоять две встроенные Ed25519-инструкции прямо перед вызовом `shine_users`: +В обычной транзакции `create/update` должны стоять две встроенные Ed25519-инструкции прямо перед вызовом `shine_users`: 1. подпись `root_key` по unsigned записи; 2. подпись `blockchain_public_key` по `LastBlockState`. @@ -459,7 +582,19 @@ topup_fee = limit_fee(additional_limit) Это правило порядка является частью контракта между off-chain клиентом и программой. -## 13. LastBlockState +Если `create_user_pda` вызывается с promo-кодом, то перед ними добавляется ещё одна Ed25519-инструкция: + +1. подпись продавца promo-логина по строке `shine_promo_v1:`; +2. подпись `root_key` по unsigned записи; +3. подпись `blockchain_public_key` по `LastBlockState`. + +Тогда программа читает: + +- `-3` — promo-подпись; +- `-2` — `root_key`; +- `-1` — `blockchain_public_key`. + +## 14. LastBlockState Сообщение для подписи `blockchain_public_key`: @@ -479,7 +614,7 @@ message_hash = SHA-256(LastBlockState bytes) signature = Ed25519(blockchain_private_key, message_hash) ``` -## 14. Валидируемые mutable-поля записи +## 15. Валидируемые mutable-поля записи Программа допускает обновление: @@ -507,7 +642,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - `blockchain_public_key` - `blockchain_type` -## 15. Правила серверных и сессионных полей +## 16. Правила серверных и сессионных полей ### UserMutableFieldsV1 @@ -567,7 +702,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - `sessions_mode = 1` - `sessions = []` -## 16. Realloc поведения PDA +## 17. Realloc поведения PDA Запись может расти. Если новая сериализованная запись длиннее текущего размера PDA: @@ -575,7 +710,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - нельзя делать чрезмерный рост одним шагом выше внутреннего лимита реализации; - перед realloc нужно обеспечить rent для нового размера. -## 17. Ошибки и классы отказа +## 18. Ошибки и классы отказа Программа должна различать как минимум такие классы ошибок: @@ -592,9 +727,14 @@ signature = Ed25519(blockchain_private_key, message_hash) - попытка уменьшить лимит/used_bytes/block number; - overflow; - неверный inflow vault; -- неверный DAO authority для economy config. +- неверный DAO authority для economy config и promo seller update; +- неверный promo-код; +- несуществующий или повреждённый `promo_seller_pda`; +- исчерпанная promo-квота; +- promo-подпись не совпадает; +- логин короче `min_login_length`. -## 18. Что должно сохраниться при переписи без Anchor +## 19. Что должно сохраниться при переписи без Anchor В текущей чисто-rust реализации уже сохранены: @@ -604,6 +744,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - тот же порядок Ed25519-инструкций; - те же immutable/mutable правила; - ту же валидацию логина и CPI в `shine_login_guard`; +- ту же promo-механику с PDA продавцов и подписью `shine_promo_v1:`; - ту же зависимость от inflow vault программы `shine_payments`. Сознательно не сохранялись: diff --git a/shine-solana/shine/programs/shine_login_guard/Cargo.toml b/shine-solana/shine/programs/shine_login_guard/Cargo.toml index 25b325e..2426b10 100644 --- a/shine-solana/shine/programs/shine_login_guard/Cargo.toml +++ b/shine-solana/shine/programs/shine_login_guard/Cargo.toml @@ -18,3 +18,8 @@ solana-program = "2.1.21" default = [] no-entrypoint = [] cpi = [] +idl-build = ["anchor-lang/idl-build"] + +[dependencies.anchor-lang] +version = "0.31.1" +optional = true diff --git a/shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/nicknames_people.txt b/shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/nicknames_people.txt index eb5e607..0113e9d 100644 --- a/shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/nicknames_people.txt +++ b/shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/nicknames_people.txt @@ -25,6 +25,8 @@ delta diesel drake duke +dryg +drug eagle echo falcon @@ -46,12 +48,14 @@ marshal master matrix maverick +mira ninja nomad onyx phantom pilot pirate +pixel predator prince pro @@ -298,6 +302,7 @@ evoker falconer fencer fierce +fire firehawk firestarter fist @@ -414,8 +419,11 @@ warpath wavebreaker westwind whitewolf -wildfire +wild +wise windstorm wolfpack wrangler zenith +zero +zorro \ No newline at end of file diff --git a/shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/people_names.txt b/shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/people_names.txt index d2ab65d..5ed6f1f 100644 --- a/shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/people_names.txt +++ b/shine-solana/shine/programs/shine_login_guard/src/dictionaries/premium/people_names.txt @@ -102,6 +102,8 @@ martin max maxim maxwell +milana +malvina michael mike nathan diff --git a/shine-solana/shine/programs/shine_login_guard/src/lib.rs b/shine-solana/shine/programs/shine_login_guard/src/lib.rs index 91111a2..11c3b5c 100644 --- a/shine-solana/shine/programs/shine_login_guard/src/lib.rs +++ b/shine-solana/shine/programs/shine_login_guard/src/lib.rs @@ -7,18 +7,17 @@ use solana_program::{ pubkey::Pubkey, }; -mod wordlist { - include!(concat!(env!("OUT_DIR"), "/generated_dictionary.rs")); -} - pub const INSTRUCTION_CLASSIFY_LOGIN: u8 = 1; pub const CLASS_FREE: u32 = 0; pub const CLASS_PREMIUM: u32 = 1; pub const CLASS_TRADEMARK: u32 = 2; -pub const MAX_WORDS_PER_LOGIN: usize = 3; pub const MAX_LOGIN_LEN: usize = 20; +// ВРЕМЕННАЯ ЗАГЛУШКА: +// до внедрения полноценной on-chain логики продажи/выдачи красивых имён +// пропускаем любой валидный логин длиной от 5 символов. +pub const TEMP_MIN_FREE_LOGIN_LEN: usize = 5; -solana_program::declare_id!("3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"); +solana_program::declare_id!("SHiGxGsXGioQYCYhchQ5R7KWoxN5UjFAFsucPf6sfnh"); entrypoint!(process_instruction); @@ -75,13 +74,11 @@ pub fn build_classify_login_instruction_data(login: &str) -> Vec { } pub fn classify(login: &str) -> u32 { + // Временный режим: словарная premium/trademark классификация отключена. let Some(normalized) = normalize_login(login) else { return CLASS_PREMIUM; }; - if let Some(v) = classify_split(&normalized) { - return v; - } - if normalized.len() <= 7 { + if normalized.len() < TEMP_MIN_FREE_LOGIN_LEN { return CLASS_PREMIUM; } CLASS_FREE @@ -107,49 +104,6 @@ fn normalize_login(login: &str) -> Option { Some(out) } -fn classify_split(login: &str) -> Option { - fn dfs(rest: &str, depth: usize, has_tm: bool) -> Option { - 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(MAX_LOGIN_LEN); - 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() -} - #[cfg(test)] mod tests { use super::*; @@ -165,12 +119,13 @@ mod tests { } #[test] - fn short_unknown_login_is_premium() { - assert_eq!(classify("abc123"), CLASS_PREMIUM); + fn login_shorter_than_temporary_threshold_is_premium() { + assert_eq!(classify("ab_1"), CLASS_PREMIUM); } #[test] - fn long_unknown_login_is_free() { + fn login_from_temporary_threshold_and_longer_is_free() { + assert_eq!(classify("abcde"), CLASS_FREE); assert_eq!(classify("abcdefgh9"), CLASS_FREE); } diff --git a/shine-solana/shine/programs/shine_payments/src/lib.rs b/shine-solana/shine/programs/shine_payments/src/lib.rs index 481a854..b905bd7 100644 --- a/shine-solana/shine/programs/shine_payments/src/lib.rs +++ b/shine-solana/shine/programs/shine_payments/src/lib.rs @@ -17,7 +17,7 @@ use std::str::FromStr; pub mod settings; -solana_program::declare_id!("c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"); +solana_program::declare_id!("SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v"); entrypoint!(process_instruction); const IX_INIT: u8 = 1; diff --git a/shine-solana/shine/programs/shine_payments/src/settings.rs b/shine-solana/shine/programs/shine_payments/src/settings.rs index 34f875a..e410ba7 100644 --- a/shine-solana/shine/programs/shine_payments/src/settings.rs +++ b/shine-solana/shine/programs/shine_payments/src/settings.rs @@ -55,4 +55,4 @@ pub const PYTH_SOL_USD_FEED_ID: &str = pub const PYTH_SOL_USD_ACCOUNT: &str = "7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE"; /// `DAO_WALLET` — адрес кошелька DAO-казны для `shine_payments`. -pub const DAO_WALLET: &str = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P"; +pub const DAO_WALLET: &str = "aiShm43fZjm3YkMs22sYL1bpXaL3bVxv7SSraPHzVgq"; diff --git a/shine-solana/shine/programs/shine_payments/web/admin_tools.html b/shine-solana/shine/programs/shine_payments/web/admin_tools.html index 526aa4d..8248dea 100644 --- a/shine-solana/shine/programs/shine_payments/web/admin_tools.html +++ b/shine-solana/shine/programs/shine_payments/web/admin_tools.html @@ -3,7 +3,7 @@ - Тех. инструменты — Shine Payments Devnet + Тех. инструменты — Shine Payments Mainnet
-

Менеджер: создание билетов (Devnet)

+

Менеджер: создание билетов (Mainnet)

Программа:
@@ -77,8 +89,8 @@