114 Commits
Author SHA256 Message Date
AidarKC 18d27c0146 Слить feature/pda-recovery-key в main 2026-07-15 19:05:13 +04:00
AidarKC 24cca1f1c6 UI: доработка регистрации и документации 2026-07-15 16:16:24 +04:00
AidarKC 118be418b5 Обновить диагностику звонков и документацию деплоя 2026-07-14 20:07:31 +04:00
AidarKCandClaude Fable 5 2a4c2c64c9 ИТХ: вход в график закрытий по доверию, а не по депозиту
- Из TODO убран депозитный/платный вход — сознательно отвергнут.
- Зафиксирована модель: DAO ведёт список как временный уровень,
  пока не сформировано сообщество сияющих; целевое состояние —
  приём/исключение серверов через голосование сияющих.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:57:12 +04:00
AidarKCandClaude Fable 5 172f007e1e Дополнить дизайн ИТХ: форки, списки доверия, децентрализация
- Раздел 6: правило признания — правило чтения (дефолт rota PDA,
  наблюдатель может считать по своему списку доверия); параллельные
  группы закрытий разрешены by design.
- Новый раздел 10 «Форки пользовательских цепочек»: fork-choice
  дежурного (длиннейшая ветка + tie-break), зачекпоинченное побеждает,
  заморозка chain_frozen только при противоречии архивов двух групп,
  рестарт с login-002, байт-в-байт повтор для клиентов.
- Шаг 9.9 проверки: конфликт -> принять версию чекпоинта (один
  пользователь больше не срывает закрытие дня).
- TODO: хэш-выбор дежурного, вход по депозиту, механизм цепочки -002.
- CHANGELOG блокчейна дополнен; формат блоков и AddBlock не менялись.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:50:41 +04:00
AidarKC 4cb98f7ca0 Обновить UI чатов и связей и добавить документацию ИТХ 2026-07-14 13:39:01 +04:00
AidarKC af12d7b954 Перенести документацию в docs и добавить социальный граф 2026-07-13 12:58:41 +04:00
AidarKC c9cfb394d7 Добавить ответы в DM и обновить UI чатов 2026-07-10 19:27:49 +04:00
AidarKC 0d2d7b6dc5 Добавить SHiNE теги в DM и безопасный рендер превью 2026-07-10 18:57:44 +04:00
AidarKC 076b43e542 Сделать DM body opaque для сервера и улучшить fallback UI 2026-07-10 18:12:17 +04:00
AidarKC cc1f78106d Обновить backup-снимок production сервера 2026-07-10 12:32:17 +04:00
AidarKC 5a5e9c01ce Обновить UI чата и закрыть проверенные DM задачи 2026-07-10 12:26:36 +04:00
AidarKC 0fb1147eb7 Зафиксировать накопленные изменения по deploy и devnet 2026-07-10 11:57:33 +04:00
AidarKC 2b23aa7b95 Исправить UI статусы DM 2026-07-10 11:57:00 +04:00
AidarKC bb92cb6b2b Исправить восстановление DM после перелогина 2026-07-10 11:47:39 +04:00
AidarKC 0730837ad7 Изолировать страницу DEVNET пополнения от mainnet 2026-07-08 20:28:29 +04:00
AidarKC 98a2631590 Довести DM v1 до соответствия коду и документации 2026-07-08 17:28:39 +04:00
AidarKC faada9dcdb Добавить временную миграцию v11 для очистки старых DM 2026-07-07 20:26:16 +04:00
AidarKC 1b7a2a8f7c Реализовать SHiNE_DM v1 с E2EE и tombstone 2026-07-07 18:07:10 +04:00
AidarKC 9c588bd9b5 Документировать новый DM-протокол и формат (в коде пока не реализовано) 2026-07-07 01:19:04 +04:00
AidarKC 31563fe9ed Добавить исторические файлы создания реального DAO 2026-07-07 01:18:16 +04:00
AidarKC 81bef1e1cc Синхронизировать изменения проекта 2026-07-05 18:18:03 +04:00
AidarKC 0240db59ee Подготовка перед деплоем в mainnet 2026-07-01 12:23:30 +04:00
AidarKC ab4dab34aa Исправить Ed25519 promo-подпись в UI 2026-06-30 18:54:39 +04:00
AidarKC 63f13a4c29 Сократить promo-аргументы регистрации в Solana 2026-06-30 18:40:33 +04:00
AidarKC 0a416a2f5c Добавить promo-регистрацию и оффлайн генератор промокодов 2026-06-30 17:39:40 +04:00
AidarKC b0887648f7 Обновить документацию и словари 2026-06-30 11:39:31 +04:00
AidarKC 5720f1cb50 Переименовать экран предоплаченного места 2026-06-29 08:45:30 +04:00
AidarKC 9324da5cb7 Разделить покупку билета на два экрана 2026-06-28 15:11:05 +04:00
AidarKC 408b0eeb39 Закрыть проверку remote AddBlock через homeserver 2026-06-28 14:49:57 +04:00
AidarKC ed83b1f906 Переделать remote AddBlock на сборку блока на homeserver 2026-06-28 14:45:29 +04:00
AidarKC 3068c3e2b8 Добавить раздел поддержки проекта Сияние 2026-06-28 13:53:12 +04:00
AidarKC 93c6f247f7 Исправить ответный SendSignal на ESP32 2026-06-28 12:29:01 +04:00
AidarKC 05a9441493 Исправить подпись SendSignal в UI 2026-06-28 12:09:13 +04:00
AidarKC aa02e92e4d Добавить SendSignal и remote AddBlock через homeserver 2026-06-28 11:20:51 +04:00
AidarKC c397c28acb Удалить старый мусор из документации 2026-06-28 10:29:27 +04:00
AidarKC c93cc6c522 Перенести backlog в TODO 2026-06-28 09:30:59 +04:00
AidarKC 0cdcc77606 Добавить TODO с планами на будущее 2026-06-26 17:55:05 +04:00
AidarKC 87eec7e5c9 Зафиксировать успешную синхронизацию на тестовом сервере 2026-06-26 17:46:34 +04:00
AidarKC 44a1ba01f3 Починить восстановление blockchain_state при full resync 2026-06-26 17:30:10 +04:00
AidarKC d49661fa29 Исправить хэш коммита в changelog блокчейна 2026-06-26 17:06:05 +04:00
AidarKC 71fdee0cfd Вернуть crash-safe запись AddBlock через tmp_bch 2026-06-26 17:05:37 +04:00
AidarKC 1ced351ea2 Закрыть проверенные pending-фичи 2026-06-26 16:56:57 +04:00
AidarKC c048347f2e Добавить startup recovery для resync цепочек 2026-06-26 15:51:52 +04:00
AidarKC be4f76834a Добавить resync блокчейна при рассинхроне 2026-06-26 15:25:11 +04:00
AidarKC 23edad416c Добавить sync-профиль пользователя и обход Solana RPC 2026-06-25 18:46:47 +04:00
AidarKC f3e4233285 Исправить загрузку внешнего application.properties 2026-06-25 18:04:04 +04:00
AidarKC 84e0f039cb Добавить периодический sync блокчейнов каждые 12 часов 2026-06-25 17:58:07 +04:00
AidarKC 1f8b20a7d1 Добавить ListBlockchainHeads для межсерверной сверки 2026-06-25 17:52:04 +04:00
AidarKC f0e1ab3af8 Перевести тестовый контур на t.shineup.me 2026-06-25 13:44:22 +04:00
AidarKC 112ab4d5d5 Улучшить звуки входящих личных сообщений 2026-06-25 12:55:42 +04:00
AidarKC 8768e142e3 Исправить мобильный ввод в личном чате 2026-06-25 11:20:30 +04:00
AidarKC 827d2e9c3e Улучшить открытие личного чата 2026-06-25 10:55:17 +04:00
AidarKC 0f3c4a621d Улучшить UX личного чата на мобильных 2026-06-25 10:46:45 +04:00
AidarKC e60475f351 Обновить синхронизацию серверов и экран сохранения ключей 2026-06-24 20:18:40 +04:00
AidarKC 0f63f7dae6 Добавить документацию по Figma 2026-06-24 16:30:16 +04:00
AidarKC 127c561a41 Слить обновления UI каналов и кошелька 2026-06-24 15:00:02 +04:00
AidarKC f9a15ab192 Обновить список каналов и кнопку сообщения 2026-06-24 14:59:08 +04:00
AidarKC 77f5759d60 Обновить UI кошелька и регистрацию 2026-06-24 13:48:07 +04:00
AidarKC 684f3237cf Исправить подключение и подпись в браузерном кошельке 2026-06-24 10:28:54 +04:00
AidarKC 23e61cc182 ESP32 регистрация и архив тестового удаления PDA 2026-06-23 20:31:05 +04:00
AidarKC d2f45ff67a ESP32: приостановить reconnect на экранах регистрации 2026-06-23 19:19:18 +04:00
AidarKC 06e12e9103 ESP32: убрать ранние checkpoint при add homeserver 2026-06-23 19:12:58 +04:00
AidarKC 29dddeff4f ESP32: убрать временный автозапуск homeserver 2026-06-23 19:03:32 +04:00
AidarKC 017d568aea ESP32: автозапуск Add Homeserver для отладки 2026-06-23 18:56:33 +04:00
AidarKC c91b52cfd2 ESP32: checkpoint перед чтением PDA homeserver 2026-06-23 18:46:41 +04:00
AidarKC 2bd38d8d78 ESP32: диагностический checkpoint для update homeserver 2026-06-23 18:39:58 +04:00
AidarKC 7d9db68d80 ESP32: NTP для update user_pda 2026-06-23 18:32:32 +04:00
AidarKC 4b94303d67 ESP32: server login и NTP для регистрации 2026-06-23 18:11:11 +04:00
AidarKC 08628704c7 UI: добавить просмотр любого PDA 2026-06-23 17:05:57 +04:00
AidarKC f1c1132690 AuthChallenge: поддержать RecoveryKeyBlock 2026-06-23 17:04:47 +04:00
AidarKC d2426c473c ESP32: убрать кэш генерации секрета 2026-06-23 14:42:02 +04:00
AidarKC 66986b804c Вынести дефолтный сервер в конфиг UI и оживить счетчик пароля 2026-06-23 14:11:09 +04:00
AidarKC 95daa230bb Обновить серверный UI под recovery key 2026-06-23 13:44:18 +04:00
AidarKC 365b22d778 ESP32: кэш последних генераций секрета 2026-06-23 12:06:24 +04:00
AidarKC cf2b54464e Исправить update user_pda для homeserver на ESP32 2026-06-23 11:42:44 +04:00
AidarKC 4e60c1274a ESP32: ускорить и упростить secret screen 2026-06-23 11:08:19 +04:00
AidarKC 2f65e63fbe ESP32: новая derivation ключей 2026-06-23 10:51:03 +04:00
AidarKC b461431197 Смена адреса shine_users и выкладка на test2 2026-06-23 10:40:52 +04:00
AidarKC 5c92b6a734 Миграция PDA на client.key 2026-06-22 21:57:09 +04:00
AidarKC ba348dafb3 Add Wallet Standard registration for browser wallet 2026-06-22 01:35:26 +04:00
AidarKC ce2d310e8c ESP32 wallet RPC, browser wallet provider, and side panel 2026-06-22 01:30:08 +04:00
AidarKC 475db28095 main: это именно та версия, которая уже стоит на тестовом продакшен сервере
Да, звучит немного удивительно, но именно так: люди уже могут регистрироваться, писать друг другу личные сообщения, звонить и пользоваться каналами.

При этом регистрация пока идёт через тестовые Solana/devnet, так что это всё ещё этап тестирования. И в UI ещё куча всего не доделана.

Но тем не менее всё равно.

Прикольно.
2026-06-21 13:14:02 +04:00
AidarKC 823a41c027 chore idea vcs mappings cleanup 2026-06-21 13:10:51 +04:00
AidarKC 2a834f1b14 fix ui dm chatid lowercase normalization 2026-06-21 12:27:41 +04:00
AidarKC c8ffb6cf29 Настроить test2 как основной контур деплоя 2026-06-20 23:34:41 +04:00
AidarKC ecc9efd434 UI: скрыть пароль при режиме 12 слов 2026-06-20 21:33:44 +04:00
AidarKC dd35e56029 Добавить временную бесплатную загрузку аватаров в Arweave 2026-06-20 21:29:35 +04:00
AidarKC d0e7998650 UI: обновить экраны входа 2026-06-20 20:15:40 +04:00
AidarKC fec5e49304 UI: FAQ регистрации и режим пароля из 12 слов 2026-06-20 19:05:45 +04:00
AidarKC 3b12e14e71 Docs: добавить идею homeserver команд и обмена файлами 2026-06-20 17:21:47 +04:00
AidarKC 86eaf2139d UI: улучшить личные сообщения и поиск контактов 2026-06-20 17:19:32 +04:00
AidarKC 65fad993ad UI: вернуть старую вкладку Личные и починить аватары в Связях 2026-06-20 16:43:53 +04:00
AidarKC 55e6e477be merge(main): объединить esp-and-wallet и UI Pixel 2026-06-20 12:17:11 +04:00
PixelandClaude Opus 4.8 ba5efcc152 Merge: UI «Связи» (финал) + редизайн «Личные» (чистый прод) в main
- Граф «Связи»: обновлён до финальной версии поверх PR #3 «pixel-связи»
  (орбы 12/13.06, единый PNG-оверлей орбов, мягкий край, вибрация выключена).
- «Личные»: редизайн списка как формы «Связей» — фото-аватары/инициалы,
  золотая галочка подтверждения у имени, значок-цепочка связи с попапом пути
  (Ты → посредники → цель) и переходом в профиль, граница карточки по типу
  связи, шапка «Shine». Данные пока мок-плейсхолдер (реальные relations/чаты —
  отдельная задача с бэкендом).
- Чистый прод: сняты обе демо-лаборатории (граф/ЛС), demo-чат, гость-обвязка
  ЛС и demo-avatars; экран «Личные» под логином.
- Сохранена работа агента в main (DM-ревизии/редактирование, wallet/pairing, esp32).
- VERSION: client 1.2.217 (server 1.2.204 без изменений).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:10:59 +03:00
PixelandClaude Opus 4.8 26253564d5 chore: bump client 1.2.169
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:10 +03:00
PixelandClaude Opus 4.8 92791c77a9 прод: убрать ЛС-лабу (demo-чат, гость-доступ, isDemo, demo-avatars); мок→плейсхолдер
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:09 +03:00
PixelandClaude Opus 4.8 465792b2ab прод: убрать граф-лабу (network/lab.js, selftest.js, граф-мок в mock-data)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:31:09 +03:00
PixelandClaude Opus 4.8 de269fd828 chore: bump client 1.2.168 + .gitignore (.claude, бэкап-ассеты)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:28 +03:00
PixelandClaude Opus 4.8 8c91484f37 nav: вкладка «Личные» (лейбл)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:27 +03:00
PixelandClaude Opus 4.8 6904ac8b7c ЛС: редизайн списка (фото-аватары, галочка/значок связи у имени, попап-цепочка→профиль) + demo-чат и lab-маршрут
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:27 +03:00
PixelandClaude Opus 4.8 aea6bbcb0e ЛС: токены связей + резолвер визуала + семантический мок (connectedVia/login)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:48:26 +03:00
AidarKC a788d8bcf5 Обновить pairing устройств и доработать ESP32 UI 2026-06-19 20:47:56 +04:00
AidarKC cc074a941f Исправить маршрутизацию call push по sessionId 2026-06-19 19:18:16 +04:00
AidarKC 47574100f9 Исправить самообрыв звонка и обновить TURN 2026-06-19 18:25:47 +04:00
PixelandClaude Opus 4.8 7ad74942e0 Связи: отключена вибрация в графе
haptic() сделан no-op — на экране «Связи» телефон не вибрирует ни на тапах по узлам,
ни на переходах (раскрытие/погружение/всплытие/пан). Вызовы haptic(...) оставлены, тело пустое.
Версия 1.2.167.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:03:32 +03:00
PixelandClaude Opus 4.8 ac1cc04637 nav: неоновые PNG-иконки вкладок бара + единый вид
- toolbar.js: data-driven иконки (iconImg/glow/hero); все 5 вкладок → <img> неон-PNG;
  «Связи» помечена hero.
- components.css: единый размер (--tab-icon-size 27px), «Связи» крупнее ВИЗУАЛЬНО через
  transform: scale (без сдвига раскладки — иконки на одной линии); active/tap-состояния;
  у «Связи» убран лишний drop-shadow-ореол (светится сама PNG); глобально
  -webkit-tap-highlight-color: transparent (нет синего tap-квадрата нигде).
- assets: icon_lichnye/kanaly/svyazi/uvedomleniya/profil.png. Иконка «Уведомления»
  приведена к прозрачному фону (была без альфы) и обрезана до ~92% заполнения, как у других.
Версия 1.2.166.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:03:04 +03:00
PixelandClaude Opus 4.8 b4480d89cf css: убрать закомментированный блок кластерной ауры (финально)
Решение по ауре финальное — не возвращаем. Удалён закомментированный «ТЕСТ»-блок
box-shadow по категориям; оставлено явное box-shadow:none для сияющих/фокуса.
Версия 1.2.165.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:13:17 +03:00
PixelandClaude Opus 4.8 ff584ba5d1 orb: мягкий край фото + убран фон-градиент категории из-под стекла
- растушёвка края фото радиальной маской (--feather-full 62% / --feather-edge 78%):
  фото сливается со стеклом без жёсткого ободка; параметры для подкрутки.
- убран фон-градиент категории на .node-dot (просвечивал через прозрачный центр
  стекла → читался как цветная «обводка»): селектор поднят до (0,4,0), чтобы
  перебить правила категории. Цвет категории остаётся на линиях.
- кластерная аура (box-shadow по категории) отключена.
Не тронуто: кромка PNG, свечение сияющих/фокуса/common, линии.
Версия 1.2.164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:55:09 +03:00
PixelandClaude Opus 4.8 69f0fdf120 orb: единый PNG-оверлей на все узлы + ретайр вектора
- glass_overlay_faithful.png в assets; орбы = фото (низ, круглая маска 78%) +
  стеклянный PNG (верх, бокс 119% от node-dot, контакт линий от ORB_R).
- PNG-оверлей применён ко ВСЕМ полным орбам (центр + спутники); tier-3 точки без изменений.
- ретайр мёртвого векторного стекла: удалён buildGlassOrb (+orbSeq) и CSS .fg-orb-svg,
  снят остаточный border .node-dot (синее кольцо) у PNG-хостов.
Версия 1.2.163.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:21:56 +03:00
PixelandClaude Opus 4.8 e3bebff618 anim (13.06): ускорение разлёта узлов — BLOOM_MS 900→550
Дети «выстреливают» из центра почти вдвое быстрее; easing и каскад (stagger)
прежние. Убирает ощущение тяжести/«подтупливания» при смене центра.
Версия 1.2.162.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:36:46 +03:00
PixelandClaude Opus 4.8 f19f7b0ec4 Связи (13.06): орбы по референсу, линии по категории, постоянная вселенная
Орбы:
- материал «хрусталь»: чистое лицо (виньетка 0.5→0.22), диффузный блик окна
  вместо «капли» (+мягкий blur sf), стекло прозрачнее (тело 0.38→0.3),
  полупрозрачная преломляющая кромка (blur + opacity 0.25→0.2).
- размер +11.5% (node-dot 52→58px); единый ORB_R=29 как источник радиуса.
- убран значок * у общих узлов (логика is-common цела).

Линии:
- цвет по категории на ВСЕХ рёбрах; плазма только сияющим.
- общий узел наследует сияние исходного человека (не серый).
- контакт линий ровно на кромке сферы орба (ORB_R), без зазора, все уровни.

Навигация:
- констелляция (паутина 2-3 уровней) — постоянный режим; кнопка «Вселенная»
  убрана; Семья/Друзья/Сияющие остаются фильтрами. Чистка осиротевшего CSS.

Версия 1.2.161.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:31:46 +03:00
PixelandClaude Opus 4.8 0b4374141e Связи (test 12.06): центр-орб крупнее (FOCUS_SCALE 1.78) + шире ореол центра (glowSpread 7)
Рычаг 1: glowSpread центра 4.5→7 (мягче/шире свечение), спутники без изменений. Рычаг 2: FOCUS_SCALE 1.5→1.78 (иерархия). Версия 1.2.160.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:53:13 +03:00
PixelandClaude Opus 4.8 652ddc9d88 Связи (test 12.06): SVG-стеклянные орбы аватаров + цвет/свечение линий глубоких связей
Аватары → SVG GlassOrb (фото в стеклянной сфере, блик, rim, свечение). Линии глубоких связей (tier-2/3) — в цвете типа (друзья/семья/...), сияющие связи светятся (голубой ореол + ядро). Версия 1.2.159.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:10:06 +03:00
499 changed files with 27956 additions and 35925 deletions
+7
View File
@@ -76,6 +76,7 @@ shine-solana/shine/scripts/**/*.env
shine-solana/shine/scripts/**/TEMP_*.md
# Локальные артефакты и внешние материалы ESP32-подпроекта
ESP32/esp32-config-tool/
ESP32/**/.git/
ESP32/**/.idea/
ESP32-wallet/.idea/
@@ -103,3 +104,9 @@ ESP32/**/*.a
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
server-backup/archive/**
!server-backup/archive/.gitkeep
# Локальная дев-обвязка Claude (дев-сервер shine-UI, сессии, планы) — не коммитим
.claude/
# Рабочие бэкапы/превью-ассеты UI — не для репозитория
*.bak.png
shine-UI/assets/navbar_preview.png
Generated
+1 -3
View File
@@ -2,7 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/official-demo" vcs="Git" />
<mapping directory="$PROJECT_DIR$/tools/understand-anything-lab/upstream" vcs="Git" />
</component>
</project>
</project>
+35 -17
View File
@@ -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,26 @@
## Deploy
- Все документы и заметки по деплою хранить в папке `Dev_Docs/deploy/`.
- Базовый целевой хост для деплоя по умолчанию: `player@93.170.12.154` (`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`.
- Для серверного деплоя использовать один gradle task: `./gradlew deployServer`.
- Для UI деплоя использовать один gradle task: `./gradlew deployUI`.
- Любые изменения и любой деплой на production `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`.
- Production UI deploy: `./gradlew deployUIProduction`.
- Резервный test deploy на `test.shineup.me`: `./gradlew deployServerTest` и `./gradlew deployUITest`, но пока их не использовать без отдельной причины.
- Для локального запуска использовать `./gradlew startLocal` (или `startLocalWithBuild`).
- Сначала предлагать локальную проверку, а деплой на сервер выполнять по запросу пользователя.
- Для временной бесплатной загрузки аватаров в Arweave секретный JWK нельзя хранить в git и нельзя прописывать в репозиторный `application.properties`.
- Для продовой настройки тестового Arweave-кошелька JWK-файл нужно хранить только на сервере, например: `/home/player/SHiNE/secrets/test-free-avatar-wallet.json`.
- Для этой временной фичи на проде должны быть заданы параметры `test.freeAvatar.walletJwkPath` и `test.freeAvatar.walletAddress` через серверный override-конфиг/секреты на хосте.
- После изменения продовых значений `test.freeAvatar.*` нужно заново выполнить серверный деплой или перезапуск сервера, чтобы настройки были перечитаны приложением.
- При таких изменениях в git допускается коммитить только документацию и код чтения настроек, но не сам JWK, не содержимое секрета и не реальные приватные ключи.
## Логи звонков (установка соединения)
- Специальный поток диагностики установки звонков идёт через `CallDeliveryReport` (клиент → сервер).
@@ -122,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-файле подробно описывать:
- какие файлы и участки отключены;
- что осталось в коде как заготовка;
- какие документы нужно обновить при возврате;
- с какого сценария продолжать разработку.
- с какого сценария продолжать разработку;
- из какого коммита брать последнюю полную реализацию.
## Коммуникация по новым задачам (обязательно)
- При получении нового задания сначала кратко пересказать задачу своими словами.
-255
View File
@@ -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`, `device 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. после этого делать синхронизацию, архив и восстановление.
-92
View File
@@ -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 <YOUR_DEBUG_TOKEN>"
```
### 2.1 Получить список живых клиентов
```bash
curl -s \
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>" \
http://localhost:7070/debug/ws/clients | jq
```
Ответ содержит `sessionId`, `login`, `ip`, `userAgent`, и клиентскую информацию.
### 2.2 Запустить debug-соединение между двумя сессиями
```bash
curl -s -X POST \
-H "Authorization: Bearer <YOUR_DEBUG_TOKEN>" \
-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 <YOUR_DEBUG_TOKEN>" \
"http://localhost:7070/debug/ws/logs?limit=200&runId=<RUN_ID>" | 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 имеет любой, кто знает токен.
- Рекомендуется тестить между разными логинами.
@@ -1,62 +0,0 @@
0. ПЕРЕДЕЛАТЬ ВСЁ НА НОВЫЙ ФОРМАТ!!
ВЫНЕСТИ ЭТИ ТРИ ВЕЩИ В ОБЩИЙ ПАРСЕР
* [2] type - тип соощения
* [2] Sиbtype - субтип сообщения
* [2] version - версия формата соощения
А ОСТАЛЬНОЕ В РЕАЛИЗАЦИЮ
ПЕРЕДЕЛАЕМ БД
1. СДЕЛАЕМ ЛИНИЮ ТОЛЬКО ДЛЯ ТЕХ ТИПОВ КОМУ НАДО (ЛАЙКАМ И ОТВЕТАМ НЕ НАДО)
(НОМЕР СООБЩЕНИЯ В ЛИНИИ ХРАНИТЬ В БЛОКАХ ВРОДЕ И НЕ НАДО ТЕМ БОЛЕЕ ЕГО ПОТОМ ПЕРЕПРОВЕРЯТЬ ВСЁ РАВНО)
А МОЖЕТ И НАДО ТК КАК ПО ОДНОМУ БЛОКУ ( ИЛИ ЧАСТИ БЛОКОВ ПОНЯТЬ КАКАЯ ЭТО ЧАСТЬ ПЕРЕПИСКИ - ВЕДЬ ГЛОБАЛ НОМЕР ВООБЩЕ НЕ ПОКАЗАТЕЛЬ)
В БД ПОМЕЧАТЬ ЧТО БЛОК ИЗ ЭТОЙ ЛИНИИ (ДЛЯ БЫСТРОГО ПОИСКА)
А УНИКАЛЬНЫЙ НОМЕР ЛИНИИ ЭТО ПО СУТИ НОМЕР СООБЩЕНИЯ СОЗДАВШЕГО ЛИНИЮ КАНАЛ (НУ И ФОРМАТ СООБЩЕНИЯ НАЧАЛА ЛИНИИ - КАНАЛА)
3. СООТВЕТСТВЕННО удалить НАПИСАТЬ/ПЕРОВЕРИТЬ НОРМАЛЬНЫЙ SubscriptionsDAO - ТК СТАРЫЙ РАБОТАЛ НО НА ДРУГОМ ФОРМАТЕ И ТИПО КРИВО
и дальше:
ЗДЕЛАТЬ ТРИ ЗАПРОСА:
СПИСОК КАНАЛОВ НА КОГО ПОДПИСАН И ПО СКОЛЬКО СООБЩЕНИЙ И ПОСЛДНИЙ ТЕКСТ
ДОДЕЛАТЬ И СВЯЗ ПОДПИСАН УЖЕ НЕ ТОЛЬКО НА ЧЕЛА НО И НА КАНАЛ. (И ПОЛУЧАЕТСЯ ЕСТЬ ОБЩИЙ КАНАЛЛ ПОСТОВ (НО НЕКОТОРЫЕ ПОСТЫ В НИКУДА-
А НЕКОТОРЫЕ ПОСТЫ ОБЪЯВЛЕНИЕ КАНАЛА
СПИСОК СООБЩЕНИЙ В КАНАЛЕ
ОПСИСАНИЕ ОДНОГО СОООБЩЕНИЯ (С ИСТОРИЕЙ ДО НАЧАЛА ВЕТКИ И СО ВСЕМИ ОТВЕТАМИ НА НЕГО)
(НУ И В БУДУЩЕМ четвёртый ИСТОРИЮ сообщения ПО ЕДИТУ)
И ПОМЯТКА
ВСЕГДА СЧИТАЕМ ПО ПОСЛЕДНЕМУ БЛОКЧЕЙНУ ДОСТУПНОМУ ПОЛЬЗОВАТЕЛЮ
ХОТЯ ССЫЛКА ПО НОМЕРУ БЛОКЧЕЙНА КУДА ДОБАВИЛИ
ЛАЙКИ И ОТВЕТЫ ПИШЕМ НА НОМЕР СООБЩЕНИЯ ЕДИТА
(СЧИТАЕМ ТРИГЕРОМ И НА ОРИГИНАЛЬНЫЙ СУМАРНОЕ И ОТДЕЛЬНО НА НЕГО, И НА КАЖДЫЙ ЕДИТ ОТДЕЛЬНО)
ОТВЕТЫ ПОКАЗЫВАЕМ ВСЕ ВРАЗ
@@ -1,10 +0,0 @@
Сделать возможность убрать свой лайк. (пока не надо а сложность что надо больше проверок) - хотя можно и без проверки, просто за двойной лайк или за снятие двойное лайка. Будет двойное проникновение :)) тому кто изменил код клиента и убрал проверку на клиенте - и блокчейн заблокируется и всё.
поэтому просто на каждую реакцию добавиться убрать эту ракцию .
- это просто
сделатьпотом что бы в солану_юзерс хранилось имя текущего блокчейна пользователя. Что бы потом можно было грузить именно актуальный ТО ЕСТЬ потом можно будет менять блокченый!
сделать сессион пасворд тоже ключём подписи устройства!!
-22
View File
@@ -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 для взаимодействия с клиентами.
-209
View File
@@ -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 — уникальный логин пользователя
device_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
device_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 — проекция активности
всё вычисляется детерминированно через триггеры
@@ -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с.
@@ -1,9 +0,0 @@
Дальше делать:
Описание форматов.
Запросы клиент-сервер.
Промт на клиента.
---
Потом в сервак дописать синхронизацию серверов.
-53
View File
@@ -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.
+96
View File
@@ -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`
+41
View File
@@ -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` делать только после отдельного подтверждения пользователя.
+150
View File
@@ -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
```
-333
View File
@@ -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-слой и политика доступа.
-48
View File
@@ -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/`.
-100
View File
@@ -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 | Нужна реализация (заглушка) |
Текущая версия сервера работает без межсерверной синхронизации.
Синхронизация — задача следующего этапа разработки.
-48
View File
@@ -1,48 +0,0 @@
# Будущие фичи
Эта папка хранит задачи, которые сознательно отложены и сейчас не должны попадать в активную разработку или ручную проверку без отдельной команды пользователя.
## Горизонты планирования
- `near/` - ближайшие планы: задачи, к которым можно вернуться сегодня или завтра.
- `medium/` - среднесрочные планы: задачи на ближайшие недели или 1-2 месяца.
- `far/` - дальнее будущее: идеи без понятного срока возврата.
Если пользователь спрашивает, какие есть планы, агент должен смотреть эти три папки и кратко перечислять задачи по горизонтам.
## Как использовать
1. Каждая будущая фича описывается отдельным markdown-файлом в одном из горизонтов.
2. В файле нужно фиксировать:
- зачем нужна фича;
- к какому сроку или горизонту она относится;
- что нужно сделать;
- какие вопросы нужно уточнить перед реализацией;
- что уже было сделано в коде, если фича частично реализована;
- что временно отключено или закомментировано, если применимо;
- какие документы нужно обновить при возврате к задаче;
- с какого места продолжать разработку.
3. Агент не должен начинать реализацию файлов из этой папки без явной просьбы пользователя.
## Текущие планы
### Ближайшие
- `near/2026-05-25_1106_telegram_agent_players.md` - разрешённые пользователи Telegram для агента, отдельные папки игроков, персональные истории и публикация краткого вопроса/ответа в общий канал.
- `near/2026-05-25_1106_wallet_topup_solana_arweave.md` - пополнение Solana и Arweave через внешний сервис покупки с подсказкой и копированием адреса.
### Среднесрочные
- `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 с версией записи.
### DAO-запуск
- `dao_запуск/2026-06-05_esp32_hardware_wallet_device_session.md` - ESP32 как аппаратный кошелёк: постоянная device-сессия на сервере, подтверждение операций на экране, делегированные сессии для браузера/телефона.
### Дальнее будущее
- Сейчас задач нет.
-5
View File
@@ -1,5 +0,0 @@
# Дальнее будущее
Сейчас в этом горизонте нет активных идей.
Сюда переносить задачи, у которых нет понятного срока возврата и которые не нужно учитывать в ближайшем или среднесрочном планировании.
@@ -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/<name>/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/<name>/` без отдельного подтверждения.
@@ -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 (между своими клиентами/агентом) в рамках одного логина.
@@ -1,73 +0,0 @@
# Черновик будущих вложений в DM
## Важно
Этот документ описывает только ранний черновик идеи.
Сейчас в проекте **нет** поддержки вложений в личных сообщениях:
- в реализованном формате `SHiNE_DM` поле `attachmentsCount` пока всегда должно быть `0`;
- UI не показывает кнопку прикрепления файлов;
- сервер не принимает upload файлов для DM;
- сервер не раздаёт специальные DM-файлы по отдельным endpoints;
- сервер не хранит отдельные файловые связи для личных сообщений.
Этот документ нужен только для того, чтобы рядом с актуальной документацией было явно видно:
- какие идеи обсуждались;
- что это **не реализовано**;
- что формат, хранение и способ загрузки потом могут сильно измениться.
## Что обсуждалось
Рассматривался такой общий подход:
- у контентного DM есть внешний список вложений;
- во внешнем формате лежат только технические данные;
- человекочитаемые данные о файле живут внутри зашифрованного тела сообщения;
- один и тот же blob-файл теоретически мог бы переиспользоваться в нескольких сообщениях.
Черновой вариант внешнего списка:
- `attachmentsCount`
- далее для каждого вложения:
- `encFileHashSHA256` (`32 bytes`)
- `encFileSize` (`u64`)
Черновой вариант внутреннего маркера в тексте:
```text
<<file:file-format(1.0):type|fileName|origSize|origHashB64u|encHashB64u|encSize|keyB64u|nonceB64u>>
```
Где обсуждались поля:
- `type`
- `fileName`
- `origSize`
- `origHashB64u`
- `encHashB64u`
- `encSize`
- `keyB64u`
- `nonceB64u`
## Что может измениться
В будущем могут измениться любые части идеи:
- сам бинарный формат;
- способ привязки файлов к сообщению;
- момент загрузки файла относительно отправки сообщения;
- серверное хранение blob-файлов;
- права доступа к скачиванию;
- способ рендера вложения в UI.
Именно поэтому этот файл не надо воспринимать как актуальную спецификацию.
## Источник истины на сейчас
Актуальное состояние личных сообщений описано только в:
- `Dev_Docs/Personal_Messages/README.md`
Если между этим черновиком и основным README есть расхождение, верным считается `README.md`.
@@ -1,29 +0,0 @@
# Сервер `93.170.12.154` — резервный
- Пользователь: `player`
- Каталог SHiNE: `/home/player/SHiNE`
- UI исходник (после rsync): `/home/player/SHiNE/SHiNE-UI`
- UI публикация для Caddy: `/var/www/shine-ui`
- Сервер: `/home/player/SHiNE/SHiNE-server/shine-server.jar`
- Данные: `/home/player/SHiNE/SHiNE-server/data/`
- `shine.sqlite`
- `*.bch`
- Логи сервера: `/home/player/SHiNE/SHiNE-server/logs/app.log`
## Сервисы
- `shine-server.service` (systemd)
- `caddy.service` (systemd)
## Статус
- Резервный сервер для SHiNE.
- Основной прод-сервер: `shineup.me` (подключение через `player@shineup.me`, IP определяется через DNS).
## Caddy
- Конфиг: `/etc/caddy/Caddyfile`
- Настройки:
- `no-store/no-cache` заголовки;
- `try_files {path} /index.html` (SPA fallback);
- `reverse_proxy /ws* -> 127.0.0.1:7070`.
@@ -1,98 +0,0 @@
# Деплой и инициализация Solana-регистрации (две обязательные программы)
## Коротко
Для рабочей регистрации пользователя нужны **обе** программы:
1. `shine_users` — хранение и обновление `user_pda`, economy-конфиг, логика регистрации.
2. `shine_login_guard` — проверка/классификация логина (CPI из `shine_users`).
Если задеплоена только одна из них — регистрация неработоспособна.
## Актуальные адреса (devnet)
- `shine_users` (регистрация пользователей):
`FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_login_guard`:
`3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- `shine_payments`:
`c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
## Подтверждение деплоя
- Сеть: `https://api.devnet.solana.com`
- `shine_users`:
- `Program ID`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- 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`
### Сервер
- Серверные константы Solana:
- `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java`
## Как запустить инициализацию 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: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`.
## Важно
- `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации.
+28
View File
@@ -0,0 +1,28 @@
# AGENTS for ESP32
## Язык UI
- Для ESP32-скетчей и экранного UI использовать английский язык.
- Русский текст на экране ESP32 пока не поддерживается корректно: шрифтовой путь для кириллицы не считается рабочим.
- Если меняется UI-скетч, все пользовательские строки на экране должны оставаться английскими, пока ограничение не снято отдельной задачей.
## Синхронизация со спецификацией
- При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/`.
## Сборка ESP32
- Основной способ проверки и прошивки скетчей для `ESP32-S3-Touch-AMOLED-2.16` - `main-device/burn.sh`.
- Не собирать эти скетчи напрямую через `arduino-cli compile` без `burn.sh`, потому что скрипт добавляет нужные локальные библиотеки и конфиги из `official-demo/examples/Arduino-v3.3.5/libraries`.
- Если сборка падает по `lv_conf.h` или `TouchDrvCSTXXX.hpp`, сначала проверять именно `burn.sh` и его `--library` пути, а не считать, что файл пропал из репозитория.
## Диагностика ESP32
- Последнюю сохранённую ошибку или диагностическую запись читать с устройства через USB serial monitor на `115200`.
- Базовая команда:
`arduino-cli monitor -p /dev/ttyACM0 --config baudrate=115200`
- После подключения отправлять одну из команд:
`last_error`, `last_diag` или `reg_diag`
- Для очистки сохранённой диагностики использовать:
`clear_error` или `clear_diag`
- При падениях в ветках регистрации и обновления PDA сначала читать именно `last_error`: запись хранится в NVS и может пережить перезагрузку устройства.
@@ -11,7 +11,7 @@
* legacy(empty password):
* secret = SHA256(base64(SHA256(password)) + "master.secret")
* keyPair_i = Ed25519(SHA256(base64(secret) + "|" + suffix_i))
* suffixes = ["root.key", "bch.key", "dev.key"]
* suffixes = ["root.key", "blockchain.key", "client.key"]
*
* Плата: Waveshare ESP32-S3-Touch-AMOLED-2.16
* SD : SDMMC 1-bit CLK=GPIO2, CMD=GPIO1, D0=GPIO3
@@ -116,8 +116,8 @@ static bool gKbNums = false;
// ═══════════════════════════════════════════════════════════
struct KeyPair { uint8_t pub[32]; uint8_t priv[32]; };
static KeyPair gKeys[3];
static const char * KEY_SUFFIXES[3] = {"root.key", "bch.key", "dev.key"};
static const char * KEY_LABELS[3] = {"root.key", "bch.key", "dev.key"};
static const char * KEY_SUFFIXES[3] = {"root.key", "blockchain.key", "client.key"};
static const char * KEY_LABELS[3] = {"root.key", "blockchain.key", "client.key"};
static uint32_t gElapsedSec = 0;
// Base58 представления (43-44 символа для 32 байт + \0)
@@ -1,7 +1,6 @@
#include "shine_secret_generation.h"
#include <SD_MMC.h>
#include <Preferences.h>
#include <mbedtls/sha256.h>
#include <mbedtls/base64.h>
#include <string.h>
@@ -80,6 +79,19 @@ static void setMessage(const char *message) {
snprintf(gMessage, sizeof(gMessage), "%s", message ? message : "");
}
static void sha256calc(const uint8_t *in, size_t len, uint8_t *out32);
static void finishSecretFromBytes(const uint8_t secret32[32], const char *message) {
memcpy(gSecret, secret32, 32);
shineSecretBase58Encode(gSecret, 32, gSecretB58, sizeof(gSecretB58));
gDone = true;
gRunning = false;
gError = false;
gInitDone = false;
gDoneBlocks = TOTAL_FILLS;
setMessage(message ? message : "Secret generated");
}
static void b2_compress(B2State *S, const uint8_t *blk) {
uint64_t m[16], v[16];
for (int i = 0; i < 16; i++) m[i] = ((const uint64_t *)blk)[i];
@@ -449,7 +461,6 @@ bool shineSecretInitSd(String &error) {
bool shineSecretStart(const char *normalizedLogin, const char *password, String &error) {
error = "";
if (!shineSecretInitSd(error)) return false;
if (!normalizedLogin || !*normalizedLogin) {
error = "login not set";
return false;
@@ -463,6 +474,8 @@ bool shineSecretStart(const char *normalizedLogin, const char *password, String
return false;
}
if (!shineSecretInitSd(error)) return false;
if (gSdFile) gSdFile.close();
SD_MMC.remove(SD_MEM_FILE);
gSdFile = SD_MMC.open(SD_MEM_FILE, "w+");
@@ -224,12 +224,14 @@ static int16_t gTouchLastY = 0;
struct DerivedKeyState {
bool ready;
uint8_t masterSecret[32];
uint8_t recoveryPub[32];
uint8_t recoverySk[64];
uint8_t rootPub[32];
uint8_t rootSk[64];
uint8_t blockchainPub[32];
uint8_t blockchainSk[64];
uint8_t devicePub[32];
uint8_t deviceSk[64];
uint8_t clientPub[32];
uint8_t clientSk[64];
};
static DerivedKeyState gDerivedKeys = {};
@@ -237,16 +239,16 @@ static DerivedKeyState gDerivedKeys = {};
static const char *kSystemProgramId = "11111111111111111111111111111111";
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
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";
static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress";
static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK";
static const uint8_t kBlockTypeRootKey = 1;
static const uint8_t kBlockTypeDeviceKey = 2;
static const uint8_t kBlockTypeClientKey = 2;
static const uint8_t kBlockTypeBlockchainRegistry = 3;
static const uint8_t kBlockTypeServerProfile = 30;
static const uint8_t kBlockTypeAccessServers = 40;
@@ -782,19 +784,20 @@ static void pushFixed(std::vector<uint8_t> &out, const uint8_t *data, size_t len
static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) {
memset(&gDerivedKeys, 0, sizeof(gDerivedKeys));
memcpy(gDerivedKeys.masterSecret, masterSecret, 32);
String secretB64 = base64Encode(masterSecret, 32);
if (secretB64.length() == 0) {
return false;
}
const char *suffixes[3] = {"root.key", "bch.key", "dev.key"};
uint8_t *pubs[3] = {gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.devicePub};
uint8_t *sks[3] = {gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.deviceSk};
for (int i = 0; i < 3; i++) {
String material = secretB64 + "|" + suffixes[i];
const char *prefix = "SHiNE-key";
const char *suffixes[4] = {"recovery.key", "root.key", "blockchain.key", "client.key"};
uint8_t *pubs[4] = {gDerivedKeys.recoveryPub, gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.clientPub};
uint8_t *sks[4] = {gDerivedKeys.recoverySk, gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.clientSk};
for (int i = 0; i < 4; i++) {
std::vector<uint8_t> material;
material.reserve(strlen(prefix) + 1 + 32 + 1 + strlen(suffixes[i]));
material.insert(material.end(), prefix, prefix + strlen(prefix));
material.push_back(0);
material.insert(material.end(), masterSecret, masterSecret + 32);
material.push_back(0);
material.insert(material.end(), suffixes[i], suffixes[i] + strlen(suffixes[i]));
uint8_t seed[32];
if (!sha256String(material, seed)) {
return false;
}
sha256Raw(material.data(), material.size(), seed);
if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) {
return false;
}
@@ -822,7 +825,7 @@ static bool restoreDerivedKeysFromSecret() {
return false;
}
gData.secretReady = true;
gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32);
gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32);
return true;
}
@@ -835,7 +838,7 @@ static bool deriveFreshSecretAndWallet() {
return false;
}
gData.secret = bytesToBase58(secret, sizeof(secret));
gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32);
gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32);
gData.secretReady = true;
return true;
}
@@ -889,7 +892,7 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
const String &blockchainName,
const String &serverAddress,
const uint8_t rootPub[32],
const uint8_t devicePub[32],
const uint8_t clientPub[32],
const uint8_t blockchainPub[32],
const uint8_t lastBlockSignature[64],
uint64_t createdAtMs) {
@@ -911,9 +914,9 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
out.push_back(0);
pushFixed(out, rootPub, 32);
out.push_back(kBlockTypeDeviceKey);
out.push_back(kBlockTypeClientKey);
out.push_back(0);
pushFixed(out, devicePub, 32);
pushFixed(out, clientPub, 32);
out.push_back(kBlockTypeBlockchainRegistry);
out.push_back(0);
@@ -960,7 +963,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
const String &blockchainName,
const String &serverAddress,
const uint8_t rootPub[32],
const uint8_t devicePub[32],
const uint8_t clientPub[32],
const uint8_t blockchainPub[32],
const uint8_t lastBlockSignature[64],
const uint8_t rootSignature[64],
@@ -972,7 +975,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
pushFixed(out, rootPub, 32);
pushU64LE(out, createdAtMs);
pushU64LE(out, 0);
pushFixed(out, devicePub, 32);
pushFixed(out, clientPub, 32);
pushFixed(out, blockchainPub, 32);
pushStrU8(out, blockchainName);
pushU64LE(out, 0);
@@ -1079,7 +1082,7 @@ static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &me
static std::vector<uint8_t> buildLegacyMessage(
const uint8_t recentBlockhash[32],
const uint8_t devicePub[32],
const uint8_t clientPub[32],
const uint8_t userPda[32],
const uint8_t inflowVault[32],
const uint8_t economyConfig[32],
@@ -1098,7 +1101,7 @@ static std::vector<uint8_t> buildLegacyMessage(
base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram);
std::vector<std::vector<uint8_t>> accountKeys;
accountKeys.emplace_back(devicePub, devicePub + 32);
accountKeys.emplace_back(clientPub, clientPub + 32);
accountKeys.emplace_back(userPda, userPda + 32);
accountKeys.emplace_back(inflowVault, inflowVault + 32);
accountKeys.emplace_back(systemProgram, systemProgram + 32);
@@ -1244,7 +1247,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
uint64_t createdAtMs = (uint64_t)millis() + 1704067200000ULL;
std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord(
gData.login, blockchainName, gData.wsUrl,
gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub,
gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub,
lastBlockSignature, createdAtMs);
uint8_t unsignedHash[32];
uint8_t rootSignature[64];
@@ -1256,7 +1259,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
std::vector<uint8_t> createData = buildCreateInstructionData(
gData.login, blockchainName, gData.wsUrl,
gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub,
gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub,
lastBlockSignature, rootSignature, createdAtMs);
std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, gDerivedKeys.rootPub, unsignedHash);
std::vector<uint8_t> edBchData = buildEd25519InstructionData(lastBlockSignature, gDerivedKeys.blockchainPub, lastBlockHash);
@@ -1269,7 +1272,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
std::vector<uint8_t> message = buildLegacyMessage(
recentBlockhash,
gDerivedKeys.devicePub,
gDerivedKeys.clientPub,
userPda,
inflowVault,
economyConfig,
@@ -1277,7 +1280,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
edBchData,
createData);
uint8_t txSignature[64];
if (!signMessageEd25519(message, gDerivedKeys.deviceSk, txSignature)) {
if (!signMessageEd25519(message, gDerivedKeys.clientSk, txSignature)) {
messageOut = "Не удалось подписать Solana-транзакцию";
return false;
}
@@ -1311,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() {
@@ -2107,7 +2110,7 @@ static void drawConfirmScreen() {
String text = "Выполнить действие?";
if (gConfirmTarget == CONFIRM_REGISTER) {
title = "Регистрация";
text = "Отправить create_user_pda в Solana через device key этого устройства?";
text = "Отправить create_user_pda в Solana через client key этого устройства?";
} else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) {
title = "Очистка";
text = "Удалить секрет, кошелёк и статус регистрации?";
@@ -70,11 +70,11 @@
Фоновая логика:
- пока открыт `HOME`, экран сам обновляется примерно раз в секунду;
- при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC;
- сравниваются `root key`, `blockchain key`, `device key` и `homeserver` session-запись типа `100`;
- сравниваются `root key`, `blockchain key`, `client key` и `homeserver` session-запись типа `100`;
- для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE:
- авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`;
- session key = публичный `homeserver key`;
- подтверждение создания сессии подписывается `device key`;
- подтверждение создания сессии подписывается `client key`;
- heartbeat выполняется `Ping` раз в минуту.
## SETTINGS_MENU
@@ -150,12 +150,12 @@
- статусное сообщение;
- текущий `Solana RPC` адрес;
- кнопку `SOLANA RPC`;
- текущий `Shine server` адрес;
- кнопку `SHINE SERVER`.
- текущий `SHiNE server login` или уже резолвленный адрес;
- кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован.
Значения по умолчанию:
- Solana RPC: `https://api.devnet.solana.com`
- Shine server: `https://shineup.me`
- Solana RPC: `https://api.mainnet-beta.solana.com`
- SHiNE server login: `shineupme`
Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`.
@@ -214,8 +214,8 @@
- `Root key priv (base58)`;
- `Blockchain key (base58)`;
- `Blockchain key priv (base58)`;
- `Device key (base58)`;
- `Device key priv (base58)`;
- `Client key (base58)`;
- `Client key priv (base58)`;
- `Homeserver key (base58)`;
- `Homeserver key priv (base58)`;
- для каждого поля показывается формула derivation;
@@ -229,7 +229,7 @@
Используется для:
- пароля Wi-Fi;
- Solana RPC;
- Shine server.
- SHiNE server login.
Показывает:
- заголовок;
@@ -259,6 +259,7 @@
- переключение страниц выполняется свайпом влево/вправо внутри `TEXT_EDIT_SCREEN`;
- страница 1 содержит в основном буквы и базовые URL-символы;
- страница 2 содержит цифры и дополнительные URL/символьные кнопки;
- на символьных страницах должны быть доступны как минимум `!` и отдельная кнопка `SPACE`;
- переключение `ABC/123` и `SHIFT` не должно очищать уже введённый текст;
- переключение страниц клавиатуры свайпом тоже не должно очищать уже введённый текст, включая цифры, символы и уже набранные заглавные буквы;
- есть специальные действия `DEL` и `CLR`.
@@ -291,7 +292,7 @@
Используется `Preferences` (NVS памяти ESP32):
- `solana_rpc`
- `shine_server`
- `shine_server_login`
## Хранение аккаунта
@@ -308,6 +309,7 @@
- половина букв находится на левой странице, половина на правой;
- на правой странице кнопки тоже стоят в ровных колонках, без сдвига рядов вправо;
- отдельный режим `symbols` тоже разделён на 2 страницы;
- в режиме `symbols` на правой странице есть отдельная кнопка `SPACE` и отдельная кнопка `!`;
- 3 основных ряда клавиш и нижний служебный ряд увеличены по высоте;
- клавиши дополнительно увеличены по высоте по сравнению с предыдущим промежуточным вариантом;
- четвёртый ряд содержит:
@@ -19,14 +19,14 @@
- локальный UI на тач-экране;
- хранение настроек и секретов во внутренней памяти `ESP32` через `NVS`;
- русский текст в логике интерфейса; в текущей временной сборке отображение на экране идёт через ASCII-транслитерацию, потому что `U8g2`-шрифты на устройстве временно не рисуются;
- экранный UI устройства работает на английском языке, потому что кириллица на этом шрифтовом пути пока не поддерживается стабильно;
- экран пополнения с реальным `solana:` URI и рисованием QR-кода через `LVGL`;
- реальное подключение к `Wi-Fi` по сохранённым `SSID/паролю`;
- реальная проверка доступности `API`, `RPC` и `WS`-адресов;
- реальное чтение баланса кошелька из `Solana RPC`;
- проверка обязательных условий перед регистрацией;
- живая on-chain регистрация серверного `user_pda` в `shine_users` по нажатию кнопки на главном экране;
- прототип входящих запросов с подтверждением и отклонением;
- живой экран заявок на подключение новых устройств через доверенную homeserver-сессию;
- PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта);
- базовые настройки, статус и главный экран;
- сохранение `PDA` и `tx signature` после успешной регистрации.
@@ -34,8 +34,8 @@
Что пока считается именно прототипом, а не финальной интеграцией:
- приём реальных входящих запросов на вход/подпись пока не подключён к живой сети;
- входящие запросы пока демонстрационные, чтобы можно было проверить UX и логику подтверждения.
- пока реализован только сценарий заявок на подключение устройств через доверенную сессию;
- другие типы входящих запросов на подпись и произвольные approval-flow на этом устройстве ещё не подключены.
## Основная идея устройства
@@ -46,7 +46,7 @@
- показывает адрес кошелька устройства;
- позволяет пополнить баланс перед регистрацией;
- после выполнения условий даёт зарегистрировать устройство как homeserver;
- после регистрации может принимать входящие запросы на вход и на подпись.
- после авторизации в SHiNE может подтверждать заявки на подключение новых устройств пользователя.
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
Основное сохранение идёт во внутреннюю flash-память через `NVS`.
@@ -65,19 +65,20 @@
- `user pda address`;
- `registration signature`;
- `balance`;
- `server api url`;
- `server rpc url`;
- `server ws url`;
- `server login` для первичной привязки;
- `resolved server api url` / `rpc url` / `ws url` после чтения PDA сервера;
- флаги:
`wifiReady`, `serversReady`, `secretReady`, `registered`, `online`.
Для первой регистрации обычного `user PDA` устройство берёт `createdAtMs` из NTP прямо перед отправкой транзакции в Solana. При последующих обновлениях `user PDA` устройство так же берёт актуальный `updatedAtMs` из NTP перед отправкой update-транзакции. Дальше в `user PDA` сохраняется `accessServers`, где по умолчанию лежит `shineupme`.
## Правило серверной сессии SHiNE
При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс:
- `sessionType = 100`
- `clientPlatform = "ESP32"`
- `clientInfo = "ESP32 homeserver"`
- `clientInfo = "ESP32 homeserver:<homeserverName>"`
Это относится и к `CreateAuthSession`, и к `SessionLogin`.
@@ -86,7 +87,7 @@
Кнопка регистрации доступна только если одновременно выполнены условия:
1. настроен и подтверждён `Wi-Fi`;
2. заполнены и подтверждены серверные адреса;
2. задан и подтверждён `SHiNE server login`;
3. задан логин;
4. сгенерирован или введён секрет;
5. баланс кошелька не меньше `0.20 SOL`;
@@ -107,22 +108,28 @@
7. `ACCOUNT`
8. `WALLET`
9. `WALLET_QR`
10. `REQUESTS`
11. `REQUEST_DETAIL`
12. `SETTINGS`
13. `PIN_EDIT`
14. `TEXT_INPUT`
15. `CONFIRM`
16. `REGISTER_ACCOUNT_CONFIRM`
17. `REGISTER_ACCOUNT_RESULT`
10. `WALLET_SIGN_REQUEST`
11. `REQUESTS`
12. `REQUEST_DETAIL`
13. `SETTINGS`
14. `PIN_EDIT`
15. `TEXT_INPUT`
16. `CONFIRM`
17. `REGISTER_ACCOUNT_CONFIRM`
18. `REGISTER_ACCOUNT_RESULT`
## Общие правила интерфейса
- Верхняя строка всегда показывает краткий статус устройства:
`PIN`, `Wi-Fi`, `сервер`, `регистрация`.
- Основной язык прототипа: русский.
- В правом верхнем углу рядом с батареей:
- сначала процент заряда;
- затем, если устройство реально заряжается, маленькая иконка молнии;
- затем контур батареи;
- затем индикатор `Wi-Fi`.
- Основной язык прототипа: английский.
- Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`.
- Русские строки на экране временно показываются в ASCII-транслитерации.
- Русские строки в UI устройства пока не использовать.
- Кнопки крупные, с тач-ориентированным размером.
- Опасные действия подтверждаются отдельным диалогом.
- После изменения данных конфигурация сразу сохраняется в `NVS`.
@@ -167,20 +174,23 @@
- блок `STATUS` поднят выше относительно предыдущей версии;
- если состояние хорошее, слово `connected` в строках `Wi-Fi` и `SHiNE` показывается зелёным.
В зоне баланса:
В зоне кошелька:
- основная кнопка показа/обновления баланса занимает примерно 80% строки;
- текст на кнопке баланса выровнен левее центра;
- справа от неё стоит отдельная кнопка `QR`;
- после старта устройства баланс пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`;
- нажатие на кнопку `QR` открывает экран `WALLET_QR`.
- вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка;
- текст кнопки: `Wallet: <selected wallet name>`;
- доступные имена:
- `ClientKey`
- `RootKey`
- либо сохранённое имя `custom`-кошелька;
- после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`;
- нажатие на эту кнопку открывает экран `WALLET`.
Нижние кнопки:
- `Статус`
- `Подключение`
- `Аккаунт`
- `Кошелёк`
- `Wallet`
- `Запросы`
- `Настройки`
@@ -405,26 +415,43 @@
Показывает:
- адрес кошелька устройства;
- баланс в `SOL`;
- минимально рекомендуемую сумму для регистрации;
- статус `Хватает / Не хватает`.
- кнопку `Wallet: <selected wallet name>`;
- строку текущего статуса/баланса активного кошелька;
- сокращённый публичный ключ активного кошелька.
Кнопки:
- `QR и URI`
- `+0.10 SOL`
- `+0.25 SOL`
- `-0.10 SOL`
- `Проверить`
- `Назад`
- `Wallet: <selected wallet name>`
- `SHOW BALANCE`
- `SHOW WALLET QR`
Поведение:
- кнопки пополнения/уменьшения нужны для теста сценариев;
- роверить` читает реальный баланс из `Solana RPC`;
- адрес кошелька должен совпадать с `device key`, вычисленным из сохранённого `master secret`;
- отрицательный баланс не допускается.
- верхняя кнопка открывает экран выбора кошелька `WALLET_SELECT`;
- оказать баланс кошелька` читает реальный баланс именно активного выбранного кошелька из `Solana RPC`;
- `Показать QR-код кошелька` открывает экран `WALLET_QR`;
- этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `client key`.
## Экран WALLET_SELECT
Показывает:
- строку `Current: <selected wallet name>`;
- три кнопки выбора:
- `ClientKey`
- `RootKey`
- `Custom` или `Custom: <имя>`;
- у текущего выбора видна галочка.
Поведение:
- `ClientKey` активирует кошелёк, выведенный из suffix `client.key`;
- `RootKey` активирует кошелёк, выведенный из suffix `root.key`;
- `Custom` использует derivation:
`sha256(base64(secret32) + "|wallet." + customName)`;
- если имя `custom`-кошелька ещё не задано, нажатие `Custom` открывает экран текстового ввода;
- если `custom` уже выбран, повторное нажатие на него открывает экран редактирования имени;
- после выбора кошелька устройство возвращается на экран `WALLET`.
## Экран WALLET_QR
@@ -447,65 +474,106 @@
Поведение:
- QR должен быть сканируемым, а не декоративным;
- адрес кошелька берётся из `device key`, вычисленного из сохранённого `master secret`;
- нажатие в любую точку экрана возвращает пользователя на `HOME`.
- адрес кошелька берётся из текущего активного выбранного кошелька;
- нажатие в любую точку экрана возвращает пользователя на `WALLET`.
## Экран REQUESTS
## Экран WALLET_SIGN_REQUEST
Показывает список демонстрационных запросов:
- `Вход в сессию`
- `Подпись сообщения`
Для каждого запроса:
- тип;
- источник;
- короткий статус.
Кнопки:
- `Открыть запрос 1`
- `Открыть запрос 2`
- `Назад`
## Экран REQUEST_DETAIL
Показывает детали выбранного запроса:
- тип запроса;
- кто запросил;
- время;
- описание;
- отпечаток/идентификатор.
Кнопки:
- `Разрешить`
- `Отклонить`
- `Назад`
Поведение:
- после разрешения или отклонения запрос помечается обработанным;
- экран списка отражает новый статус.
## Экран SETTINGS
Экран показывается, когда браузерное wallet-расширение присылает на ESP32 запрос `sign_transaction`.
Показывает:
- текущий PIN;
- базовые флаги безопасности;
- технические действия прототипа.
- заголовок `SIGN REQUEST`;
- вопрос о подписи транзакции текущим активным кошельком;
- сокращённый публичный ключ кошелька;
- комментарий, присланный полем `comment`;
- две кнопки:
- `REJECT`
- `APPROVE`
Поведение:
- если пользователь нажимает `APPROVE`, ESP32 подписывает транзакцию текущим активным кошельком и отправляет ответ в расширение;
- если пользователь нажимает `REJECT`, ESP32 отправляет отказ `rejected_by_user`;
- пока запрос активен, свайпом этот экран не закрывается;
- после ответа расширению устройство возвращается на предыдущий экран.
## Экран REQUESTS
Экран доступен только если homeserver уже авторизован в SHiNE.
Показывает:
- слева сверху кнопку `REFRESH`;
- заголовок `REQUESTS:` немного правее стандартного левого положения;
- справа сверху только большую цифру числа активных заявок;
- ниже прокручиваемый список активных pairing-заявок;
- на экране одновременно видны примерно две плитки.
Каждая плитка показывает:
- код подключения из `10` цифр в виде `5` пар: `XX XX XX XX XX`;
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`.
Поведение:
- список берётся из живой операции `ListTrustedDeviceLoginRequests`;
- если заявок нет, экран показывает `No active requests`;
- отдельная строка вида `Active requests: N` на этом экране не показывается;
- вертикальный скролл позволяет просматривать все активные заявки;
- нажатие по плитке открывает `REQUEST_DETAIL`;
- свайп вправо возвращает в `SETTINGS`.
## Экран REQUEST_DETAIL
Показывает детали выбранной pairing-заявки:
- вопрос `Connect session ...?`;
- код подключения `XX XX XX XX XX`;
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`;
- пояснение:
- для client session: `Only client key will be transferred. No additional keys will be sent.`
- для wallet session: `No keys will be transferred.`
Кнопки:
- `Сменить PIN`
- `Сбросить онлайн`
- `Полный сброс`
- `Назад`
- `YES`
- `NO`
`Полный сброс` очищает весь локальный конфиг и возвращает устройство к стартовому состоянию.
Поведение:
- `YES` подтверждает заявку:
- для client session устройство передаёт только `client key`;
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
- `NO` отклоняет заявку;
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
- свайп вправо возвращает в `REQUESTS`.
## Экран SETTINGS
Показывает вертикальное меню крупных пунктов.
Если homeserver ещё не авторизован в SHiNE:
- `1. Wi-Fi`
- `2. Server`
- `3. Account`
Если homeserver уже авторизован:
- `1. Device requests`
- `2. Wi-Fi`
- `3. Server`
- `4. Account`
Поведение:
- одновременно видны только две карточки;
- список листается свайпом вверх/вниз;
- свайп вправо возвращает на `HOME`;
- пункт `Device requests` должен быть первым и появляется только для авторизованного homeserver.
## Экран PIN_EDIT
@@ -561,7 +629,7 @@
2. открыть `Подключение -> Wi-Fi`;
3. ввести `SSID` и пароль, нажать `Проверить`;
4. открыть `Подключение -> Серверы`;
5. проверить или задать серверные адреса;
5. проверить или задать `SHiNE server login` (по умолчанию `shineupme`);
6. открыть `Аккаунт`;
7. ввести логин;
8. задать имя homeserver;
@@ -570,14 +638,15 @@
11. при необходимости пополнить баланс;
12. вернуться на `HOME`;
13. нажать `REGISTER ACCOUNT`;
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1` и при необходимости сообщение о неподключённом `Wi-Fi`;
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1`, серверный login и при необходимости сообщение о неподключённом `Wi-Fi`;
15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`;
16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку;
17. после успешной регистрации увидеть статус `Homeserver активен`.
Примечание:
- устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`.
- устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`;
- при первой регистрации для обычного `user PDA` не заполняется `serverAddress`, а `accessServers` получает `shineupme` или другой выбранный `SHiNE server login`.
## Сценарий входящего запроса
@@ -591,8 +660,8 @@
В текущей диагностической версии:
- строковые литералы в коде остаются русскими и в `UTF-8`;
- перед выводом на экран они временно транслитерируются в ASCII;
- строковые литералы экранного UI должны оставаться английскими ASCII-совместимыми;
- возвращение кириллицы допустимо только после отдельной доработки шрифтов и реальной проверки на устройстве;
- рендер выполняется стандартным шрифтом `Arduino_GFX`;
- это обходной режим, пока `U8g2`-шрифты на устройстве не начнут рисоваться стабильно.
@@ -601,7 +670,7 @@
Минимально нужно проверить:
1. устройство загружается и сразу открывает `HOME`; экран блокировки временно отключён;
2. текст отображается читаемо хотя бы в ASCII-транслитерации;
2. весь экранный текст отображается читаемо на английском без замены символов;
3. ввод по экранной клавиатуре работает;
4. после перезагрузки сохранённые поля остаются в памяти;
5. секрет и адрес кошелька сохраняются на устройстве;
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+2 -2
View File
@@ -48,13 +48,13 @@
- Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе.
## Планы и отложенные фичи
- Планы проекта по отложенным фичам хранятся в `Dev_Docs/Future_Features/`.
- Планы проекта по отложенным фичам хранятся в `TODO/`.
- Внутри есть три горизонта:
- `near/` - ближайшие планы, обычно сегодня/завтра;
- `medium/` - среднесрочные планы, обычно недели или 1-2 месяца;
- `far/` - дальнее будущее без понятного срока.
- Если пользователь спрашивает, какие есть планы или что можно продолжить, нужно смотреть эти три папки и отвечать кратким списком по горизонтам.
- Файлы из `Dev_Docs/Future_Features/` не начинать реализовывать без явной команды пользователя.
- Файлы из `TODO/` не начинать реализовывать без явной команды пользователя.
- После реализации фичи, требующей ручной проверки, нужно добавить отдельный файл в `Dev_Docs/Pending_Features/`.
## Центр задач и предложений
+3 -3
View File
@@ -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, когда это уместно.
+8 -3
View File
@@ -7,11 +7,13 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
- создать `wallet-session` через `StartTrustedDeviceLogin`;
- показать код подключения;
- дождаться подтверждения на доверенном устройстве;
- принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`;
- принять `session-only` payload без передачи `clientKey/rootKey/blockchainKey`;
- сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin;
- восстанавливать session через `SessionChallenge -> SessionLogin`;
- держать wallet-state в `background service worker`, а popup использовать как UI.
- держать wallet-state в `background service worker`, а side panel использовать как UI.
- принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA.
- внедрять legacy `window.solana` / `window.phantom.solana` provider для сайтов.
- регистрировать кошелёк как `Wallet Standard` wallet для dapp, которые ищут стандартные кошельки.
## Как загрузить локально
@@ -19,13 +21,16 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
2. Включи `Developer mode`
3. Нажми `Load unpacked`
4. Выбери папку `SHiNE-browser-plugin-wallet/`
5. Нажми на иконку расширения в toolbar: откроется side panel SHiNE Wallet
## Ограничения текущего этапа
- plugin пока не держит постоянный фоновый WS-канал после закрытия popup, но хранит wallet-state в `background`;
- plugin пока не держит постоянный фоновый WS-канал без активного действия пользователя, но хранит wallet-state в `background`;
- на этом этапе реализован только `session-only login`;
- запросы на подпись будут следующим этапом.
- pairing-пароль, если он используется, должен генерироваться в формате `sha256$<hex>` от строки `shine-pairing|loginLower|password`.
- сторона side panel в Chromium выбирается самим браузером/пользователем; extension не закрепляет панель принудительно слева.
- для совместимости с некоторыми dapp расширение одновременно держит и legacy provider, и Wallet Standard регистрацию.
## Сборка crypto bundle
+458 -67
View File
@@ -26,19 +26,101 @@ const state = {
connectionOnline: false,
walletProfile: null,
signing: {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
},
currentWallet: null,
pendingApprovals: [],
siteApprovalChain: Promise.resolve(),
sessionAttachInProgress: false,
statusText: '',
statusKind: 'info',
};
const WALLET_RPC_REQUEST_TYPE = 9100;
const WALLET_RPC_RESPONSE_TYPE = 9101;
async function configureSidePanelBehavior() {
if (!chrome.sidePanel?.setPanelBehavior) {
return;
}
try {
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
} catch (error) {
console.warn('Failed to configure SHiNE side panel behavior:', error);
}
}
function normalizeOrigin(value = '') {
const raw = String(value || '').trim();
if (!raw) return '';
try {
return new URL(raw).origin;
} catch {
return raw;
}
}
function makeCodeError(message, code) {
const error = new Error(String(message || 'Wallet error'));
error.code = String(code || '').trim().toUpperCase();
return error;
}
function setStatus(message = '', kind = 'info') {
state.statusText = String(message || '');
state.statusKind = kind === 'error' ? 'error' : 'info';
}
function makePendingApprovalSnapshot(payload = {}) {
const pendingId = String(payload?.id || '').trim();
const pendingApprovals = Array.isArray(state.pendingApprovals) ? state.pendingApprovals : [];
const queueIndex = pendingApprovals.findIndex((item) => String(item?.id || '').trim() === pendingId);
const queueLength = pendingApprovals.length;
return {
id: pendingId,
kind: String(payload?.kind || 'sign_transaction').trim() || 'sign_transaction',
origin: String(payload?.origin || '').trim(),
publicKeyBase58: String(payload?.publicKeyBase58 || '').trim(),
comment: String(payload?.comment || '').trim(),
createdAtMs: Number(payload?.createdAtMs || Date.now()),
status: String(payload?.status || 'queued').trim() || 'queued',
queuePosition: queueIndex >= 0 ? queueIndex + 1 : 1,
queueLength: queueLength || 1,
transactionSummary: payload?.transactionSummary && typeof payload.transactionSummary === 'object'
? { ...payload.transactionSummary }
: null,
};
}
function getCurrentPendingApproval() {
return Array.isArray(state.pendingApprovals) && state.pendingApprovals.length
? state.pendingApprovals[0]
: null;
}
function removePendingApproval(pendingId, { rejectError = null } = {}) {
const pendingApprovals = Array.isArray(state.pendingApprovals) ? state.pendingApprovals : [];
const index = pendingApprovals.findIndex((item) => String(item?.id || '').trim() === String(pendingId || '').trim());
if (index < 0) return;
const [pending] = pendingApprovals.splice(index, 1);
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
if (rejectError && pending.abortController) {
try {
pending.abortController.abort(rejectError);
} catch {}
}
}
async function openSidePanelForSender(sender) {
if (!chrome.sidePanel?.open || !sender?.tab?.id) return;
try {
await chrome.sidePanel.open({ tabId: sender.tab.id });
} catch {}
}
function stopPoll() {
if (state.pollTimer) {
clearTimeout(state.pollTimer);
@@ -71,14 +153,18 @@ async function loadStateFromStorage() {
serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'),
serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
login: String(settings?.login || '').trim(),
connectedOrigins: Array.isArray(settings?.connectedOrigins) ? settings.connectedOrigins.map((item) => normalizeOrigin(item)).filter(Boolean) : [],
};
state.activeSession = await loadSessionMaterial();
const storedSession = await loadSessionMaterial();
if (storedSession || !state.sessionAttachInProgress) {
state.activeSession = storedSession;
}
state.walletProfile = state.activeSession?.walletProfile || null;
state.signing = {
selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'),
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
};
state.currentWallet = state.activeSession?.currentWallet || null;
}
async function persistSettings(nextSettings = {}) {
@@ -86,10 +172,29 @@ async function persistSettings(nextSettings = {}) {
...state.settings,
...nextSettings,
};
if (!Array.isArray(state.settings.connectedOrigins)) {
state.settings.connectedOrigins = [];
}
await savePluginSettings(state.settings);
return state.settings;
}
function isOriginApproved(origin) {
const normalized = normalizeOrigin(origin);
return !!normalized && Array.isArray(state.settings.connectedOrigins) && state.settings.connectedOrigins.includes(normalized);
}
async function setOriginApproved(origin, approved) {
const normalized = normalizeOrigin(origin);
const current = new Set(Array.isArray(state.settings.connectedOrigins) ? state.settings.connectedOrigins : []);
if (approved) {
if (normalized) current.add(normalized);
} else if (normalized) {
current.delete(normalized);
}
await persistSettings({ connectedOrigins: [...current] });
}
async function resolveServerForLogin(login) {
const cleanLogin = String(login || state.settings.login || '').trim();
if (!cleanLogin) {
@@ -119,9 +224,9 @@ async function saveActiveSessionRecord() {
const nextRecord = {
...state.activeSession,
walletProfile: state.walletProfile,
selectedKeyId: state.signing.selectedKeyId,
selectedDeviceName: state.signing.selectedDeviceName,
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
currentWallet: state.currentWallet,
};
state.activeSession = nextRecord;
await saveSessionMaterial(nextRecord);
@@ -152,27 +257,10 @@ function toWalletErrorMessage(error, fallback = 'Не удалось выпол
return raw || fallback;
}
function buildSigningKeyOptions(walletProfile) {
const rootKey = String(walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
const options = [];
if (rootKey) {
options.push({
id: 'root',
label: `rootKey (ed25519, ${shortKey(rootKey)})`,
keyType: 'ed25519',
publicKeyBase58: rootKey,
});
}
if (deviceKey) {
options.push({
id: 'device',
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
keyType: 'ed25519',
publicKeyBase58: deviceKey,
});
}
return options;
function homeserverSessionNameFromClientInfo(value = '') {
const raw = String(value || '').trim();
const match = raw.match(/^ESP32 homeserver:(.+)$/i);
return match ? String(match[1] || '').trim() : '';
}
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
@@ -181,18 +269,25 @@ function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = [])
? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100)
: [];
const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer);
const byName = new Map();
onlineHomeservers.forEach((item) => {
const sessionName = homeserverSessionNameFromClientInfo(item?.clientInfoFromClient);
if (sessionName) {
byName.set(sessionName, item);
}
});
return published.map((item) => {
let onlineState = 'unknown';
if (published.length === 1) {
onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline';
} else if (onlineHomeservers.length === 0) {
onlineState = 'offline';
} else if (onlineHomeservers.length === published.length) {
const matched = byName.get(String(item?.sessionName || '').trim()) || null;
let onlineState = matched ? 'online' : 'offline';
let activeSessionId = matched?.sessionId ? String(matched.sessionId) : '';
if (!matched && published.length === 1 && onlineHomeservers.length === 1) {
onlineState = 'online';
activeSessionId = String(onlineHomeservers[0]?.sessionId || '');
}
return {
...item,
activeSessionId,
onlineState,
onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown',
};
@@ -203,25 +298,20 @@ async function hydrateWalletProfile(login) {
const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim();
if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.');
const profile = await readWalletProfileByLogin(cleanLogin);
const signingKeyOptions = buildSigningKeyOptions(profile);
const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId)
? state.signing.selectedKeyId
: (signingKeyOptions[0]?.id || '');
const selectedDeviceName = state.signing.selectedDeviceName
|| String(profile?.homeserverSessions?.[0]?.sessionName || '');
state.walletProfile = {
...profile,
signingKeyOptions,
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
...item,
onlineState: 'unknown',
onlineLabel: 'unknown',
activeSessionId: '',
})) : [],
};
state.signing = {
...state.signing,
selectedKeyId,
selectedDeviceName,
};
await saveActiveSessionRecord();
@@ -282,17 +372,30 @@ async function attachApprovedSession(payload) {
throw new Error('Получен неполный session-only payload');
}
await clearSessionMaterial();
state.activeSession = sessionRecord;
await hydrateWalletProfile(login);
await saveActiveSessionRecord();
await persistSettings({
login: sessionRecord.login,
serverLogin: sessionRecord.serverLogin,
serverHttp: sessionRecord.serverHttp,
serverUrl: sessionRecord.serverUrl,
});
state.connectionOnline = false;
state.sessionAttachInProgress = true;
try {
state.activeSession = sessionRecord;
state.walletProfile = null;
state.currentWallet = null;
state.signing = {
...state.signing,
selectedDeviceName: '',
devicesResolvedAtMs: 0,
};
await saveActiveSessionRecord();
await hydrateWalletProfile(login);
await saveActiveSessionRecord();
await persistSettings({
login: sessionRecord.login,
serverLogin: sessionRecord.serverLogin,
serverHttp: sessionRecord.serverHttp,
serverUrl: sessionRecord.serverUrl,
});
state.connectionOnline = false;
state.currentWallet = null;
} finally {
state.sessionAttachInProgress = false;
}
}
async function pollPairingStatus() {
@@ -362,7 +465,7 @@ async function startPairing({ login, usePassword, password }) {
state.pairingId = String(payload?.pairingId || '').trim();
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
state.shortCode = String(payload?.shortCode || '0000000');
state.shortCode = String(payload?.shortCode || '');
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
if (!state.pairingId) {
throw new Error('Сервер не вернул pairingId.');
@@ -375,7 +478,7 @@ async function startPairing({ login, usePassword, password }) {
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
return {
pairingId: state.pairingId,
shortCode: String(payload?.shortCode || '0000000'),
shortCode: String(payload?.shortCode || ''),
expiresAtMs: state.expiresAtMs,
trustedSessionOnline: !!payload?.trustedSessionOnline,
};
@@ -400,10 +503,10 @@ async function disconnectSession() {
state.connectionOnline = false;
state.walletProfile = null;
state.signing = {
selectedKeyId: 'device',
selectedDeviceName: '',
devicesResolvedAtMs: 0,
};
state.currentWallet = null;
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
return { ok: true };
}
@@ -440,40 +543,296 @@ async function refreshWalletDevices() {
}
}
async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) {
async function updateSigningSelection({ selectedDeviceName } = {}) {
state.signing = {
...state.signing,
selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''),
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
};
await saveActiveSessionRecord();
return { ok: true };
}
async function prepareSignSignal() {
async function resolveSelectedHomeserverSession() {
if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.');
}
if (!state.signing.selectedKeyId) {
throw new Error('Не выбран ключ подписи.');
}
if (!state.signing.selectedDeviceName) {
throw new Error('Не выбрано устройство homeserver.');
}
const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
let selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
if (!selectedDevice) {
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
}
setStatus(
`Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`,
'info',
);
if (!selectedDevice.activeSessionId) {
await refreshWalletDevices();
selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
}
if (!selectedDevice?.activeSessionId) {
throw new Error('Выбранный homeserver сейчас не найден онлайн на сервере SHiNE.');
}
return selectedDevice;
}
async function callWalletRpc(requestData, timeoutMs = 8000, abortSignal = null) {
const selectedDevice = await resolveSelectedHomeserverSession();
const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) {
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
}
const requestId = String(requestData?.requestId || `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`);
const callId = `wallet-rpc-${requestId}`;
const payload = {
...requestData,
requestId,
timeMs: Number(requestData?.timeMs || Date.now()),
};
try {
const response = await new Promise((resolve, reject) => {
let settled = false;
let timeoutId = 0;
let off = () => {};
let removeAbortListener = () => {};
const cleanup = () => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
off();
removeAbortListener();
};
timeoutId = setTimeout(() => {
cleanup();
reject(new Error('Таймаут ответа от ESP32.'));
}, timeoutMs);
off = ensureApi().onEvent('IncomingCallSignal', (evt) => {
const eventPayload = evt?.payload || {};
if (String(eventPayload?.callId || '') !== callId) return;
if (Number(eventPayload?.type || 0) !== WALLET_RPC_RESPONSE_TYPE) return;
cleanup();
try {
resolve(JSON.parse(String(eventPayload?.data || '{}')));
} catch {
reject(new Error('ESP32 вернул некорректный JSON.'));
}
});
if (abortSignal) {
const onAbort = () => {
cleanup();
reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error('Ожидание подписи отменено.'));
};
if (abortSignal.aborted) {
onAbort();
return;
}
abortSignal.addEventListener('abort', onAbort, { once: true });
removeAbortListener = () => {
abortSignal.removeEventListener('abort', onAbort);
};
}
ensureApi().callSignalToSession({
toLogin: state.activeSession.login,
targetSessionId: selectedDevice.activeSessionId,
callId,
type: WALLET_RPC_REQUEST_TYPE,
data: JSON.stringify(payload),
}).catch((error) => {
cleanup();
reject(error);
});
});
return { response, selectedDevice, requestId };
} finally {
ensureApi().close();
state.api = null;
state.connectionOnline = false;
}
}
function verifyWalletAgainstPda(wallet) {
const type = String(wallet?.type || '').trim();
const pub = String(wallet?.publicKeyBase58 || '').trim();
const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const clientKey = String(
state.walletProfile?.publicKeys?.clientKeyBase58 || '',
).trim();
if (type === 'client.key') {
return {
verified: !!clientKey && clientKey === pub,
verificationText: clientKey === pub ? 'Совпадает с clientKey из PDA.' : 'Не совпадает с clientKey из PDA.',
};
}
if (type === 'root.key') {
return {
verified: !!rootKey && rootKey === pub,
verificationText: rootKey === pub ? 'Совпадает с rootKey из PDA.' : 'Не совпадает с rootKey из PDA.',
};
}
return {
verified: null,
verificationText: 'Для custom-кошелька проверка через PDA пока не выполняется.',
};
}
async function requestCurrentWallet() {
const { response, selectedDevice, requestId } = await callWalletRpc({
v: 1,
operation: 'get_wallet_public_key',
requestId: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
});
if (!response?.ok) {
throw new Error(`ESP32 отклонил запрос: ${String(response?.error || 'unknown_error')}`);
}
state.currentWallet = {
type: String(response?.wallet?.type || '').trim(),
publicKeyBase58: String(response?.wallet?.publicKeyBase58 || '').trim(),
homeserverName: selectedDevice.sessionName,
requestId: String(response?.requestId || requestId),
timeMs: Number(response?.timeMs || 0),
...verifyWalletAgainstPda(response?.wallet || {}),
};
await saveActiveSessionRecord();
setStatus(`Кошелёк получен с ${selectedDevice.sessionName}.`, 'info');
return { ok: true, wallet: state.currentWallet };
}
async function cancelPendingSiteApproval() {
const pending = getCurrentPendingApproval();
if (!pending) {
setStatus('Сейчас нет активного ожидания подписи.', 'info');
return { ok: true };
}
removePendingApproval(pending.id, {
rejectError: makeCodeError('User canceled request in extension.', 'USER_REJECTED'),
});
setStatus('Ожидание подписи отменено в расширении.', 'info');
return { ok: true };
}
async function markPendingSiteApprovalResolved(pendingId) {
removePendingApproval(pendingId);
}
function enqueueSiteApproval(work) {
const run = state.siteApprovalChain.then(work, work);
state.siteApprovalChain = run.catch(() => {});
return run;
}
async function activatePendingApproval(pending, sender = null) {
const abortController = new AbortController();
pending.status = 'active';
pending.abortController = abortController;
pending.timeoutId = setTimeout(() => {
removePendingApproval(pending.id, {
rejectError: makeCodeError('Signing request timed out in extension.', 'USER_REJECTED'),
});
setStatus('Ожидание подписи истекло в расширении.', 'error');
}, 120000);
setStatus(`Сайт ${pending.origin} запросил подпись. Подтвердите или отмените на доверенном устройстве.`, 'info');
await openSidePanelForSender(sender);
return pending;
}
function beginSiteTransactionFlow(payload = {}) {
const pending = makePendingApprovalSnapshot({
...payload,
kind: 'sign_transaction',
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
createdAtMs: Date.now(),
status: 'queued',
});
state.pendingApprovals.push({
...pending,
timeoutId: 0,
abortController: null,
});
return pending;
}
async function siteConnect({ origin, onlyIfTrusted = false } = {}) {
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) {
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
}
if (onlyIfTrusted && !isOriginApproved(normalizedOrigin)) {
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
}
const result = await requestCurrentWallet();
const publicKeyBase58 = String(result?.wallet?.publicKeyBase58 || '').trim();
if (!publicKeyBase58) {
throw makeCodeError('Wallet public key is not available.', 'WALLET_UNAVAILABLE');
}
if (!isOriginApproved(normalizedOrigin)) {
await setOriginApproved(normalizedOrigin, true);
}
setStatus(`Site ${normalizedOrigin} connected to ${shortKey(publicKeyBase58, 8)}.`, 'info');
return {
ok: true,
pending: true,
publicKeyBase58,
walletType: String(result?.wallet?.type || '').trim(),
};
}
async function siteDisconnect({ origin } = {}) {
const normalizedOrigin = normalizeOrigin(origin);
setStatus(normalizedOrigin ? `Site ${normalizedOrigin} disconnected.` : 'Site disconnected.', 'info');
return { ok: true };
}
async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment, transactionSummary } = {}, sender = null) {
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) {
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
}
if (!isOriginApproved(normalizedOrigin)) {
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
}
const cleanPub = String(publicKeyBase58 || '').trim();
const cleanTx = String(transactionBase64 || '').trim();
if (!cleanPub || !cleanTx) {
throw makeCodeError('Transaction payload is incomplete.', 'BAD_REQUEST');
}
const pending = beginSiteTransactionFlow({
origin: normalizedOrigin,
publicKeyBase58: cleanPub,
comment: String(comment || '').trim(),
transactionSummary: transactionSummary || null,
});
return enqueueSiteApproval(async () => {
await activatePendingApproval(getCurrentPendingApproval() || pending, sender);
const activePending = getCurrentPendingApproval() || pending;
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const signComment = String(comment || '').trim() || `Site ${normalizedOrigin} requested transaction signature`;
try {
const { response } = await callWalletRpc({
v: 1,
operation: 'sign_transaction',
requestId,
publicKeyBase58: cleanPub,
transactionBase64: cleanTx,
comment: signComment,
}, 120000, activePending.abortController?.signal || null);
if (!response?.ok) {
const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase();
if (errorCode === 'REJECTED_BY_USER') {
throw makeCodeError('User rejected transaction signature on ESP32.', 'USER_REJECTED');
}
throw makeCodeError(`ESP32 rejected transaction signature: ${String(response?.error || 'unknown_error')}`, errorCode || 'RPC_REJECTED');
}
setStatus(`Подпись для ${normalizedOrigin} завершена.`, 'info');
return {
ok: true,
publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(),
signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(),
signatureBase58: String(response?.signatureBase58 || '').trim(),
};
} finally {
await markPendingSiteApprovalResolved(activePending.id);
}
});
}
function snapshot() {
return {
settings: { ...state.settings },
@@ -485,8 +844,10 @@ function snapshot() {
trustedSessionOnline: state.trustedSessionOnline,
},
session: state.activeSession ? { ...state.activeSession } : null,
connectionOnline: state.connectionOnline,
connectionOnline: !!state.activeSession,
walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
currentWallet: state.currentWallet ? { ...state.currentWallet } : null,
pendingApproval: getCurrentPendingApproval() ? makePendingApprovalSnapshot(getCurrentPendingApproval()) : null,
signing: { ...state.signing },
status: {
text: state.statusText,
@@ -538,8 +899,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:prepareSignSignal') {
const result = await prepareSignSignal();
if (type === 'wallet:requestCurrentWallet') {
const result = await requestCurrentWallet();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
@@ -548,15 +909,45 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:cancelPendingSiteApproval') {
const result = await cancelPendingSiteApproval();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteConnect') {
const result = await siteConnect(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteDisconnect') {
const result = await siteDisconnect(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteSignTransaction') {
const result = await siteSignTransaction(message?.payload || {}, _sender);
sendResponse({ ok: true, result, state: snapshot() });
return;
}
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
})().catch((error) => {
const message = toWalletErrorMessage(error, 'Unknown error');
setStatus(message, 'error');
sendResponse({ ok: false, error: message, state: snapshot() });
sendResponse({ ok: false, error: message, code: String(error?.code || ''), state: snapshot() });
});
return true;
});
chrome.runtime.onInstalled.addListener(() => {
void configureSidePanelBehavior();
});
chrome.runtime.onStartup.addListener(() => {
void configureSidePanelBehavior();
});
void configureSidePanelBehavior();
void loadStateFromStorage().then(async () => {
if (state.activeSession?.login) {
await hydrateWalletProfile(state.activeSession.login).catch(() => {});
@@ -0,0 +1,93 @@
const PAGE_REQUEST = 'shine-wallet-page-request';
const PAGE_RESPONSE = 'shine-wallet-page-response';
const PAGE_MESSAGE_TARGET_ORIGIN = '*';
function injectProviderBridge() {
const root = document.head || document.documentElement;
if (!root) return;
const script = document.createElement('script');
script.type = 'module';
script.src = chrome.runtime.getURL('provider-bridge.js');
script.dataset.shineWalletProvider = '1';
root.appendChild(script);
script.remove();
}
function respondToPage(id, ok, result, error, code) {
window.postMessage({
target: PAGE_RESPONSE,
id: String(id || ''),
ok: !!ok,
result: result || null,
error: error ? String(error) : '',
code: code ? String(code) : '',
}, PAGE_MESSAGE_TARGET_ORIGIN);
}
function sendRuntimeMessage(type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
const raw = String(chrome.runtime.lastError.message || 'Runtime message failed');
if (/Extension context invalidated/i.test(raw)) {
const error = new Error('Расширение было перезагружено или отключено. Обновите страницу и откройте кошелёк заново.');
error.code = 'EXTENSION_CONTEXT_INVALIDATED';
reject(error);
return;
}
reject(new Error(raw));
return;
}
if (!response?.ok) {
const error = new Error(String(response?.error || 'Wallet operation failed'));
error.code = String(response?.code || '');
reject(error);
return;
}
resolve(response);
});
});
}
window.addEventListener('message', (event) => {
if (event.source !== window) return;
const data = event.data || {};
if (data?.target !== PAGE_REQUEST) return;
const id = String(data?.id || '');
const method = String(data?.method || '');
const params = data?.params || {};
const origin = window.location.origin;
(async () => {
if (method === 'connect') {
const response = await sendRuntimeMessage('wallet:siteConnect', {
origin,
onlyIfTrusted: !!params?.onlyIfTrusted,
});
respondToPage(id, true, response.result || null);
return;
}
if (method === 'disconnect') {
const response = await sendRuntimeMessage('wallet:siteDisconnect', { origin });
respondToPage(id, true, response.result || null);
return;
}
if (method === 'signTransaction') {
const response = await sendRuntimeMessage('wallet:siteSignTransaction', {
origin,
publicKeyBase58: String(params?.publicKeyBase58 || '').trim(),
transactionBase64: String(params?.transactionBase64 || '').trim(),
comment: String(params?.comment || '').trim(),
transactionSummary: params?.transactionSummary || null,
});
respondToPage(id, true, response.result || null);
return;
}
respondToPage(id, false, null, 'Unsupported provider method', 'UNSUPPORTED_METHOD');
})().catch((error) => {
respondToPage(id, false, null, error?.message || 'Wallet bridge error', error?.code || '');
});
});
injectProviderBridge();
@@ -59,6 +59,15 @@ export async function createRequesterPairingMaterial() {
};
}
export function normalizePairingShortCode(value, digits = 10) {
return String(value || '').replace(/\D+/g, '').slice(0, digits).padStart(digits, '0');
}
export function formatPairingShortCode(value) {
const normalized = normalizePairingShortCode(value, 10);
return normalized.match(/.{1,2}/g)?.join(' ') || normalized;
}
export async function deriveEspPairingPasswordHash(login, password) {
const loginLower = String(login || '').trim().toLowerCase();
const preimage = `${PAIRING_HASH_VERSION}|${loginLower}|${String(password ?? '')}`;
@@ -75,6 +75,22 @@ export class ShineApiClient {
return Array.isArray(response?.payload?.sessions) ? response.payload.sessions : [];
}
async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) {
const response = await this.ws.request('CallSignalToSession', {
toLogin: String(toLogin || '').trim(),
targetSessionId: String(targetSessionId || '').trim(),
callId: String(callId || '').trim(),
type: Number(type) || 0,
data: String(data || ''),
});
if (response.status !== 200) throw opError('CallSignalToSession', response);
return response.payload || {};
}
onEvent(op, handler) {
return this.ws.on(op, handler);
}
async resumeSession(sessionRecord) {
const login = String(sessionRecord?.login || '').trim();
const sessionId = String(sessionRecord?.sessionId || '').trim();
@@ -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 = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
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';
@@ -69,8 +69,9 @@ function parseServerFieldsFromUserPda(dataBytes) {
let isServer = false;
let serverAddress = '';
let accessServers = [];
let recoveryKey32 = null;
let rootKey32 = null;
let deviceKey32 = null;
let clientKey32 = null;
let blockchainKey32 = null;
let blockchainName = '';
let homeserverSessions = [];
@@ -79,10 +80,11 @@ function parseServerFieldsFromUserPda(dataBytes) {
const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version
if (blockType === 1 || blockType === 2) {
if (blockType === 0 || blockType === 1 || blockType === 2) {
const key32 = readBytes(bytes, cursorRef, 32);
if (blockType === 0) recoveryKey32 = key32;
if (blockType === 1) rootKey32 = key32;
if (blockType === 2) deviceKey32 = key32;
if (blockType === 2) clientKey32 = key32;
continue;
}
if (blockType === 3) {
@@ -150,8 +152,9 @@ function parseServerFieldsFromUserPda(dataBytes) {
serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: {
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
deviceKeyBase58: deviceKey32 ? new PublicKey(deviceKey32).toBase58() : '',
clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName,
},
@@ -12136,8 +12136,8 @@ function weierstrass(curveDef) {
return drbg(seed, k2sig);
}
Point2.BASE._setWindowSize(8);
function verify2(signature, msgHash, publicKey2, opts = defaultVerOpts) {
const sg = signature;
function verify2(signature2, msgHash, publicKey2, opts = defaultVerOpts) {
const sg = signature2;
msgHash = ensureBytes("msgHash", msgHash);
publicKey2 = ensureBytes("publicKey", publicKey2);
if ("strict" in opts)
@@ -12515,30 +12515,30 @@ var PACKET_DATA_SIZE = 1280 - 40 - 8;
var VERSION_PREFIX_MASK = 127;
var SIGNATURE_LENGTH_IN_BYTES = 64;
var TransactionExpiredBlockheightExceededError = class extends Error {
constructor(signature) {
super(`Signature ${signature} has expired: block height exceeded.`);
constructor(signature2) {
super(`Signature ${signature2} has expired: block height exceeded.`);
this.signature = void 0;
this.signature = signature;
this.signature = signature2;
}
};
Object.defineProperty(TransactionExpiredBlockheightExceededError.prototype, "name", {
value: "TransactionExpiredBlockheightExceededError"
});
var TransactionExpiredTimeoutError = class 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.`);
constructor(signature2, timeoutSeconds) {
super(`Transaction was not confirmed in ${timeoutSeconds.toFixed(2)} seconds. It is unknown if it succeeded or failed. Check signature ${signature2} using the Solana Explorer or CLI tools.`);
this.signature = void 0;
this.signature = signature;
this.signature = signature2;
}
};
Object.defineProperty(TransactionExpiredTimeoutError.prototype, "name", {
value: "TransactionExpiredTimeoutError"
});
var TransactionExpiredNonceInvalidError = class extends Error {
constructor(signature) {
super(`Signature ${signature} has expired: the nonce is no longer valid.`);
constructor(signature2) {
super(`Signature ${signature2} has expired: the nonce is no longer valid.`);
this.signature = void 0;
this.signature = signature;
this.signature = signature2;
}
};
Object.defineProperty(TransactionExpiredNonceInvalidError.prototype, "name", {
@@ -12598,6 +12598,9 @@ var MessageAccountKeys = class {
var publicKey = (property = "publicKey") => {
return BufferLayout.blob(32, property);
};
var signature = (property = "signature") => {
return BufferLayout.blob(64, property);
};
var rustString = (property = "string") => {
const rsl = BufferLayout.struct([BufferLayout.u32("length"), BufferLayout.u32("lengthPadding"), BufferLayout.blob(BufferLayout.offset(BufferLayout.u32(), -8), "chars")], property);
const _decode = rsl.decode.bind(rsl);
@@ -12954,6 +12957,260 @@ var Message = class _Message {
return new _Message(messageArgs);
}
};
var MessageV0 = class _MessageV0 {
constructor(args) {
this.header = void 0;
this.staticAccountKeys = void 0;
this.recentBlockhash = void 0;
this.compiledInstructions = void 0;
this.addressTableLookups = void 0;
this.header = args.header;
this.staticAccountKeys = args.staticAccountKeys;
this.recentBlockhash = args.recentBlockhash;
this.compiledInstructions = args.compiledInstructions;
this.addressTableLookups = args.addressTableLookups;
}
get version() {
return 0;
}
get numAccountKeysFromLookups() {
let count = 0;
for (const lookup of this.addressTableLookups) {
count += lookup.readonlyIndexes.length + lookup.writableIndexes.length;
}
return count;
}
getAccountKeys(args) {
let accountKeysFromLookups;
if (args && "accountKeysFromLookups" in args && args.accountKeysFromLookups) {
if (this.numAccountKeysFromLookups != args.accountKeysFromLookups.writable.length + args.accountKeysFromLookups.readonly.length) {
throw new Error("Failed to get account keys because of a mismatch in the number of account keys from lookups");
}
accountKeysFromLookups = args.accountKeysFromLookups;
} else if (args && "addressLookupTableAccounts" in args && args.addressLookupTableAccounts) {
accountKeysFromLookups = this.resolveAddressTableLookups(args.addressLookupTableAccounts);
} else if (this.addressTableLookups.length > 0) {
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 < this.header.numRequiredSignatures;
}
isAccountWritable(index) {
const numSignedAccounts = this.header.numRequiredSignatures;
const numStaticAccountKeys = this.staticAccountKeys.length;
if (index >= numStaticAccountKeys) {
const lookupAccountKeysIndex = index - numStaticAccountKeys;
const numWritableLookupAccountKeys = this.addressTableLookups.reduce((count, lookup) => count + lookup.writableIndexes.length, 0);
return lookupAccountKeysIndex < numWritableLookupAccountKeys;
} else if (index >= this.header.numRequiredSignatures) {
const unsignedAccountIndex = index - numSignedAccounts;
const numUnsignedAccounts = numStaticAccountKeys - numSignedAccounts;
const numWritableUnsignedAccounts = numUnsignedAccounts - this.header.numReadonlyUnsignedAccounts;
return unsignedAccountIndex < numWritableUnsignedAccounts;
} else {
const numWritableSignedAccounts = numSignedAccounts - this.header.numReadonlySignedAccounts;
return index < numWritableSignedAccounts;
}
}
resolveAddressTableLookups(addressLookupTableAccounts) {
const accountKeysFromLookups = {
writable: [],
readonly: []
};
for (const tableLookup of this.addressTableLookups) {
const tableAccount = addressLookupTableAccounts.find((account) => account.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 (index < tableAccount.state.addresses.length) {
accountKeysFromLookups.writable.push(tableAccount.state.addresses[index]);
} else {
throw new Error(`Failed to find address for index ${index} in address lookup table ${tableLookup.accountKey.toBase58()}`);
}
}
for (const index of tableLookup.readonlyIndexes) {
if (index < tableAccount.state.addresses.length) {
accountKeysFromLookups.readonly.push(tableAccount.state.addresses[index]);
} else {
throw new Error(`Failed to find address for index ${index} in address lookup table ${tableLookup.accountKey.toBase58()}`);
}
}
}
return accountKeysFromLookups;
}
static compile(args) {
const compiledKeys = CompiledKeys.compile(args.instructions, args.payerKey);
const addressTableLookups = new Array();
const accountKeysFromLookups = {
writable: new Array(),
readonly: new Array()
};
const lookupTableAccounts = args.addressLookupTableAccounts || [];
for (const lookupTable of lookupTableAccounts) {
const extractResult = compiledKeys.extractTableLookup(lookupTable);
if (extractResult !== void 0) {
const [addressTableLookup, {
writable,
readonly
}] = extractResult;
addressTableLookups.push(addressTableLookup);
accountKeysFromLookups.writable.push(...writable);
accountKeysFromLookups.readonly.push(...readonly);
}
}
const [header, staticAccountKeys] = compiledKeys.getMessageComponents();
const accountKeys = new MessageAccountKeys(staticAccountKeys, accountKeysFromLookups);
const compiledInstructions = accountKeys.compileInstructions(args.instructions);
return new _MessageV0({
header,
staticAccountKeys,
recentBlockhash: args.recentBlockhash,
compiledInstructions,
addressTableLookups
});
}
serialize() {
const encodedStaticAccountKeysLength = Array();
encodeLength(encodedStaticAccountKeysLength, this.staticAccountKeys.length);
const serializedInstructions = this.serializeInstructions();
const encodedInstructionsLength = Array();
encodeLength(encodedInstructionsLength, this.compiledInstructions.length);
const serializedAddressTableLookups = this.serializeAddressTableLookups();
const encodedAddressTableLookupsLength = Array();
encodeLength(encodedAddressTableLookupsLength, this.addressTableLookups.length);
const messageLayout = BufferLayout.struct([BufferLayout.u8("prefix"), BufferLayout.struct([BufferLayout.u8("numRequiredSignatures"), BufferLayout.u8("numReadonlySignedAccounts"), BufferLayout.u8("numReadonlyUnsignedAccounts")], "header"), BufferLayout.blob(encodedStaticAccountKeysLength.length, "staticAccountKeysLength"), BufferLayout.seq(publicKey(), this.staticAccountKeys.length, "staticAccountKeys"), publicKey("recentBlockhash"), BufferLayout.blob(encodedInstructionsLength.length, "instructionsLength"), BufferLayout.blob(serializedInstructions.length, "serializedInstructions"), BufferLayout.blob(encodedAddressTableLookupsLength.length, "addressTableLookupsLength"), BufferLayout.blob(serializedAddressTableLookups.length, "serializedAddressTableLookups")]);
const serializedMessage = new Uint8Array(PACKET_DATA_SIZE);
const MESSAGE_VERSION_0_PREFIX = 1 << 7;
const serializedMessageLength = messageLayout.encode({
prefix: MESSAGE_VERSION_0_PREFIX,
header: this.header,
staticAccountKeysLength: new Uint8Array(encodedStaticAccountKeysLength),
staticAccountKeys: this.staticAccountKeys.map((key) => key.toBytes()),
recentBlockhash: import_bs58.default.decode(this.recentBlockhash),
instructionsLength: new Uint8Array(encodedInstructionsLength),
serializedInstructions,
addressTableLookupsLength: new Uint8Array(encodedAddressTableLookupsLength),
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 = BufferLayout.struct([BufferLayout.u8("programIdIndex"), BufferLayout.blob(encodedAccountKeyIndexesLength.length, "encodedAccountKeyIndexesLength"), BufferLayout.seq(BufferLayout.u8(), instruction.accountKeyIndexes.length, "accountKeyIndexes"), BufferLayout.blob(encodedDataLength.length, "encodedDataLength"), BufferLayout.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 = BufferLayout.struct([publicKey("accountKey"), BufferLayout.blob(encodedWritableIndexesLength.length, "encodedWritableIndexesLength"), BufferLayout.seq(BufferLayout.u8(), lookup.writableIndexes.length, "writableIndexes"), BufferLayout.blob(encodedReadonlyIndexesLength.length, "encodedReadonlyIndexesLength"), BufferLayout.seq(BufferLayout.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;
assert2(prefix !== maskedPrefix, `Expected versioned message but received legacy message`);
const version2 = maskedPrefix;
assert2(version2 === 0, `Expected versioned message with version 0 but found version ${version2}`);
const header = {
numRequiredSignatures: guardedShift(byteArray),
numReadonlySignedAccounts: guardedShift(byteArray),
numReadonlyUnsignedAccounts: guardedShift(byteArray)
};
const staticAccountKeys = [];
const staticAccountKeysLength = decodeLength(byteArray);
for (let i2 = 0; i2 < staticAccountKeysLength; i2++) {
staticAccountKeys.push(new PublicKey(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH)));
}
const recentBlockhash = import_bs58.default.encode(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH));
const instructionCount = decodeLength(byteArray);
const compiledInstructions = [];
for (let i2 = 0; i2 < instructionCount; i2++) {
const programIdIndex = guardedShift(byteArray);
const accountKeyIndexesLength = decodeLength(byteArray);
const accountKeyIndexes = guardedSplice(byteArray, 0, accountKeyIndexesLength);
const dataLength = decodeLength(byteArray);
const data = new Uint8Array(guardedSplice(byteArray, 0, dataLength));
compiledInstructions.push({
programIdIndex,
accountKeyIndexes,
data
});
}
const addressTableLookupsCount = decodeLength(byteArray);
const addressTableLookups = [];
for (let i2 = 0; i2 < addressTableLookupsCount; i2++) {
const accountKey = new PublicKey(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH));
const writableIndexesLength = decodeLength(byteArray);
const writableIndexes = guardedSplice(byteArray, 0, writableIndexesLength);
const readonlyIndexesLength = decodeLength(byteArray);
const readonlyIndexes = guardedSplice(byteArray, 0, readonlyIndexesLength);
addressTableLookups.push({
accountKey,
writableIndexes,
readonlyIndexes
});
}
return new _MessageV0({
header,
staticAccountKeys,
recentBlockhash,
compiledInstructions,
addressTableLookups
});
}
};
var VersionedMessage = {
deserializeMessageVersion(serializedMessage) {
const prefix = serializedMessage[0];
const maskedPrefix = prefix & VERSION_PREFIX_MASK;
if (maskedPrefix === prefix) {
return "legacy";
}
return maskedPrefix;
},
deserialize: (serializedMessage) => {
const version2 = VersionedMessage.deserializeMessageVersion(serializedMessage);
if (version2 === "legacy") {
return Message.from(serializedMessage);
}
if (version2 === 0) {
return MessageV0.deserialize(serializedMessage);
} else {
throw new Error(`Transaction message version ${version2} deserialization is not supported`);
}
}
};
var DEFAULT_SIGNATURE = import_buffer2.Buffer.alloc(SIGNATURE_LENGTH_IN_BYTES).fill(0);
var TransactionInstruction = class {
constructor(opts) {
@@ -13196,9 +13453,9 @@ var Transaction = class _Transaction {
isWritable: true
});
}
for (const signature of this.signatures) {
for (const signature2 of this.signatures) {
const uniqueIndex = uniqueMetas.findIndex((x) => {
return x.pubkey.equals(signature.publicKey);
return x.pubkey.equals(signature2.publicKey);
});
if (uniqueIndex > -1) {
if (!uniqueMetas[uniqueIndex].isSigner) {
@@ -13206,7 +13463,7 @@ var Transaction = class _Transaction {
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()}`);
throw new Error(`unknown signer: ${signature2.publicKey.toString()}`);
}
}
let numRequiredSignatures = 0;
@@ -13392,8 +13649,8 @@ var Transaction = class _Transaction {
_partialSign(message, ...signers) {
const signData = message.serialize();
signers.forEach((signer) => {
const signature = sign(signData, signer.secretKey);
this._addSignature(signer.publicKey, toBuffer(signature));
const signature2 = sign(signData, signer.secretKey);
this._addSignature(signer.publicKey, toBuffer(signature2));
});
}
/**
@@ -13404,20 +13661,20 @@ var Transaction = class _Transaction {
* @param {PublicKey} pubkey Public key that will be added to the transaction.
* @param {Buffer} signature An externally created signature to add to the transaction.
*/
addSignature(pubkey, signature) {
addSignature(pubkey, signature2) {
this._compile();
this._addSignature(pubkey, signature);
this._addSignature(pubkey, signature2);
}
/**
* @internal
*/
_addSignature(pubkey, signature) {
assert2(signature.length === 64);
_addSignature(pubkey, signature2) {
assert2(signature2.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 = import_buffer2.Buffer.from(signature);
this.signatures[index].signature = import_buffer2.Buffer.from(signature2);
}
/**
* Verify signatures of a Transaction
@@ -13436,15 +13693,15 @@ var Transaction = class _Transaction {
_getMessageSignednessErrors(message, requireAllSignatures) {
const errors = {};
for (const {
signature,
signature: signature2,
publicKey: publicKey2
} of this.signatures) {
if (signature === null) {
if (signature2 === null) {
if (requireAllSignatures) {
(errors.missing ||= []).push(publicKey2);
}
} else {
if (!verify(signature, message, publicKey2.toBytes())) {
if (!verify(signature2, message, publicKey2.toBytes())) {
(errors.invalid ||= []).push(publicKey2);
}
}
@@ -13498,11 +13755,11 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
assert2(signatures.length < 256);
import_buffer2.Buffer.from(signatureCount).copy(wireTransaction, 0);
signatures.forEach(({
signature
signature: signature2
}, index) => {
if (signature !== null) {
assert2(signature.length === 64, `signature has invalid length`);
import_buffer2.Buffer.from(signature).copy(wireTransaction, signatureCount.length + index * 64);
if (signature2 !== null) {
assert2(signature2.length === 64, `signature has invalid length`);
import_buffer2.Buffer.from(signature2).copy(wireTransaction, signatureCount.length + index * 64);
}
});
signData.copy(wireTransaction, signatureCount.length + signatures.length * 64);
@@ -13545,8 +13802,8 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
const signatureCount = decodeLength(byteArray);
let signatures = [];
for (let i2 = 0; i2 < signatureCount; i2++) {
const signature = guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES);
signatures.push(import_bs58.default.encode(import_buffer2.Buffer.from(signature)));
const signature2 = guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES);
signatures.push(import_bs58.default.encode(import_buffer2.Buffer.from(signature2)));
}
return _Transaction.populate(Message.from(byteArray), signatures);
}
@@ -13564,9 +13821,9 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
if (message.header.numRequiredSignatures > 0) {
transaction.feePayer = message.accountKeys[0];
}
signatures.forEach((signature, index) => {
signatures.forEach((signature2, index) => {
const sigPubkeyPair = {
signature: signature == import_bs58.default.encode(DEFAULT_SIGNATURE) ? null : import_bs58.default.decode(signature),
signature: signature2 == import_bs58.default.encode(DEFAULT_SIGNATURE) ? null : import_bs58.default.decode(signature2),
publicKey: message.accountKeys[index]
};
transaction.signatures.push(sigPubkeyPair);
@@ -13591,6 +13848,65 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
return transaction;
}
};
var VersionedTransaction = class _VersionedTransaction {
get version() {
return this.message.version;
}
constructor(message, signatures) {
this.signatures = void 0;
this.message = void 0;
if (signatures !== void 0) {
assert2(signatures.length === message.header.numRequiredSignatures, "Expected signatures length to be equal to the number of required signatures");
this.signatures = signatures;
} else {
const defaultSignatures = [];
for (let i2 = 0; i2 < message.header.numRequiredSignatures; i2++) {
defaultSignatures.push(new Uint8Array(SIGNATURE_LENGTH_IN_BYTES));
}
this.signatures = defaultSignatures;
}
this.message = message;
}
serialize() {
const serializedMessage = this.message.serialize();
const encodedSignaturesLength = Array();
encodeLength(encodedSignaturesLength, this.signatures.length);
const transactionLayout = BufferLayout.struct([BufferLayout.blob(encodedSignaturesLength.length, "encodedSignaturesLength"), BufferLayout.seq(signature(), this.signatures.length, "signatures"), BufferLayout.blob(serializedMessage.length, "serializedMessage")]);
const serializedTransaction = new Uint8Array(2048);
const serializedTransactionLength = transactionLayout.encode({
encodedSignaturesLength: new Uint8Array(encodedSignaturesLength),
signatures: this.signatures,
serializedMessage
}, serializedTransaction);
return serializedTransaction.slice(0, serializedTransactionLength);
}
static deserialize(serializedTransaction) {
let byteArray = [...serializedTransaction];
const signatures = [];
const signaturesLength = decodeLength(byteArray);
for (let i2 = 0; i2 < signaturesLength; i2++) {
signatures.push(new Uint8Array(guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES)));
}
const message = VersionedMessage.deserialize(new Uint8Array(byteArray));
return new _VersionedTransaction(message, signatures);
}
sign(signers) {
const messageData = this.message.serialize();
const signerPubkeys = this.message.staticAccountKeys.slice(0, this.message.header.numRequiredSignatures);
for (const signer of signers) {
const signerIndex = signerPubkeys.findIndex((pubkey) => pubkey.equals(signer.publicKey));
assert2(signerIndex >= 0, `Cannot sign with non signer key ${signer.publicKey.toBase58()}`);
this.signatures[signerIndex] = sign(messageData, signer.secretKey);
}
}
addSignature(publicKey2, signature2) {
assert2(signature2.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(publicKey2));
assert2(signerIndex >= 0, `Can not add signature; \`${publicKey2.toBase58()}\` is not required to sign this transaction`);
this.signatures[signerIndex] = signature2;
}
};
var NUM_TICKS_PER_SECOND = 160;
var DEFAULT_TICKS_PER_SLOT = 64;
var NUM_SLOTS_PER_SECOND = NUM_TICKS_PER_SECOND / DEFAULT_TICKS_PER_SLOT;
@@ -13607,7 +13923,7 @@ var SYSVAR_STAKE_HISTORY_PUBKEY = new PublicKey("SysvarStakeHistory1111111111111
var SendTransactionError = class extends Error {
constructor({
action,
signature,
signature: signature2,
transactionMessage,
logs
}) {
@@ -13617,7 +13933,7 @@ ${JSON.stringify(logs.slice(-10), null, 2)}. ` : "";
let message;
switch (action) {
case "send":
message = `Transaction ${signature} resulted in an error.
message = `Transaction ${signature2} resulted in an error.
${transactionMessage}. ` + maybeLogsOutput + guideText;
break;
case "simulate":
@@ -13633,7 +13949,7 @@ Message: ${transactionMessage}.
this.signature = void 0;
this.transactionMessage = void 0;
this.transactionLogs = void 0;
this.signature = signature;
this.signature = signature2;
this.transactionMessage = transactionMessage;
this.transactionLogs = logs ? logs : void 0;
}
@@ -13675,12 +13991,12 @@ async function sendAndConfirmTransaction(connection, transaction, signers, optio
maxRetries: options.maxRetries,
minContextSlot: options.minContextSlot
};
const signature = await connection.sendTransaction(transaction, signers, sendOptions);
const signature2 = await connection.sendTransaction(transaction, signers, sendOptions);
let status;
if (transaction.recentBlockhash != null && transaction.lastValidBlockHeight != null) {
status = (await connection.confirmTransaction({
abortSignal: options?.abortSignal,
signature,
signature: signature2,
blockhash: transaction.recentBlockhash,
lastValidBlockHeight: transaction.lastValidBlockHeight
}, options && options.commitment)).value;
@@ -13694,25 +14010,25 @@ async function sendAndConfirmTransaction(connection, transaction, signers, optio
minContextSlot: transaction.minNonceContextSlot,
nonceAccountPubkey,
nonceValue: transaction.nonceInfo.nonce,
signature
signature: signature2
}, 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;
status = (await connection.confirmTransaction(signature2, options && options.commitment)).value;
}
if (status.err) {
if (signature != null) {
if (signature2 != null) {
throw new SendTransactionError({
action: "send",
signature,
signature: signature2,
transactionMessage: `Status: (${JSON.stringify(status)})`
});
}
throw new Error(`Transaction ${signature} failed (${JSON.stringify(status)})`);
throw new Error(`Transaction ${signature2} failed (${JSON.stringify(status)})`);
}
return signature;
return signature2;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -15218,14 +15534,14 @@ var Ed25519Program = class _Ed25519Program {
const {
publicKey: publicKey2,
message,
signature,
signature: signature2,
instructionIndex
} = params;
assert2(publicKey2.length === PUBLIC_KEY_BYTES$1, `Public Key must be ${PUBLIC_KEY_BYTES$1} bytes but received ${publicKey2.length} bytes`);
assert2(signature.length === SIGNATURE_BYTES, `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature.length} bytes`);
assert2(signature2.length === SIGNATURE_BYTES, `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature2.length} bytes`);
const publicKeyOffset = ED25519_INSTRUCTION_LAYOUT.span;
const signatureOffset = publicKeyOffset + publicKey2.length;
const messageDataOffset = signatureOffset + signature.length;
const messageDataOffset = signatureOffset + signature2.length;
const numSignatures = 1;
const instructionData = import_buffer2.Buffer.alloc(messageDataOffset + message.length);
const index = instructionIndex == null ? 65535 : instructionIndex;
@@ -15241,7 +15557,7 @@ var Ed25519Program = class _Ed25519Program {
messageInstructionIndex: index
}, instructionData);
instructionData.fill(publicKey2, publicKeyOffset);
instructionData.fill(signature, signatureOffset);
instructionData.fill(signature2, signatureOffset);
instructionData.fill(message, messageDataOffset);
return new TransactionInstruction({
keys: [],
@@ -15263,11 +15579,11 @@ var Ed25519Program = class _Ed25519Program {
try {
const keypair = Keypair.fromSecretKey(privateKey);
const publicKey2 = keypair.publicKey.toBytes();
const signature = sign(message, keypair.secretKey);
const signature2 = sign(message, keypair.secretKey);
return this.createInstructionWithPublicKey({
publicKey: publicKey2,
message,
signature,
signature: signature2,
instructionIndex
});
} catch (error) {
@@ -15277,8 +15593,8 @@ var Ed25519Program = class _Ed25519Program {
};
Ed25519Program.programId = new PublicKey("Ed25519SigVerify111111111111111111111111111");
var ecdsaSign = (msgHash, privKey) => {
const signature = secp256k1.sign(msgHash, privKey);
return [signature.toCompactRawBytes(), signature.recovery];
const signature2 = secp256k1.sign(msgHash, privKey);
return [signature2.toCompactRawBytes(), signature2.recovery];
};
secp256k1.utils.isValidPrivateKey;
var publicKeyCreate = secp256k1.getPublicKey;
@@ -15316,14 +15632,14 @@ var Secp256k1Program = class _Secp256k1Program {
const {
publicKey: publicKey2,
message,
signature,
signature: signature2,
recoveryId,
instructionIndex
} = params;
return _Secp256k1Program.createInstructionWithEthAddress({
ethAddress: _Secp256k1Program.publicKeyToEthAddress(publicKey2),
message,
signature,
signature: signature2,
recoveryId,
instructionIndex
});
@@ -15336,7 +15652,7 @@ var Secp256k1Program = class _Secp256k1Program {
const {
ethAddress: rawAddress,
message,
signature,
signature: signature2,
recoveryId,
instructionIndex = 0
} = params;
@@ -15354,7 +15670,7 @@ var Secp256k1Program = class _Secp256k1Program {
const dataStart = 1 + SIGNATURE_OFFSETS_SERIALIZED_SIZE;
const ethAddressOffset = dataStart;
const signatureOffset = dataStart + ethAddress.length;
const messageDataOffset = signatureOffset + signature.length + 1;
const messageDataOffset = signatureOffset + signature2.length + 1;
const numSignatures = 1;
const instructionData = import_buffer2.Buffer.alloc(SECP256K1_INSTRUCTION_LAYOUT.span + message.length);
SECP256K1_INSTRUCTION_LAYOUT.encode({
@@ -15366,7 +15682,7 @@ var Secp256k1Program = class _Secp256k1Program {
messageDataOffset,
messageDataSize: message.length,
messageInstructionIndex: instructionIndex,
signature: toBuffer(signature),
signature: toBuffer(signature2),
ethAddress: toBuffer(ethAddress),
recoveryId
}, instructionData);
@@ -15396,11 +15712,11 @@ var Secp256k1Program = class _Secp256k1Program {
/* isCompressed */
).slice(1);
const messageHash = import_buffer2.Buffer.from(keccak_256(toBuffer(message)));
const [signature, recoveryId] = ecdsaSign(messageHash, privateKey);
const [signature2, recoveryId] = ecdsaSign(messageHash, privateKey);
return this.createInstructionWithPublicKey({
publicKey: publicKey2,
message,
signature,
signature: signature2,
recoveryId,
instructionIndex
});
@@ -16179,7 +16495,9 @@ var VoteAccountLayout = BufferLayout.struct([
BufferLayout.struct([BufferLayout.nu64("slot"), BufferLayout.nu64("timestamp")], "lastTimestamp")
]);
export {
PublicKey
PublicKey,
Transaction,
VersionedTransaction
};
/*! Bundled license information:
@@ -1,3 +1,3 @@
import { PublicKey } from '@solana/web3.js';
import { PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js';
export { PublicKey };
export { PublicKey, Transaction, VersionedTransaction };
@@ -24,6 +24,7 @@ export class WsJsonClient {
this.ws = null;
this.openPromise = null;
this.pending = new Map();
this.listeners = new Map();
}
async open() {
@@ -78,14 +79,53 @@ export class WsJsonClient {
} catch {
return;
}
if (data?.event) {
this.emit(String(data?.op || ''), data);
return;
}
const requestId = data?.requestId;
if (!requestId) return;
if (!requestId) {
this.emit(String(data?.op || ''), data);
return;
}
const slot = this.pending.get(requestId);
if (!slot) return;
if (!slot) {
this.emit(String(data?.op || ''), data);
return;
}
this.pending.delete(requestId);
slot.resolve(data);
}
on(op, handler) {
const key = String(op || '').trim();
if (!key || typeof handler !== 'function') {
return () => {};
}
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key).add(handler);
return () => {
const bucket = this.listeners.get(key);
if (!bucket) return;
bucket.delete(handler);
if (!bucket.size) {
this.listeners.delete(key);
}
};
}
emit(op, payload) {
const bucket = this.listeners.get(String(op || '').trim());
if (!bucket?.size) return;
for (const handler of [...bucket]) {
try {
handler(payload);
} catch {}
}
}
failPending(message) {
const error = new Error(message);
for (const slot of this.pending.values()) slot.reject(error);
+28 -3
View File
@@ -4,7 +4,8 @@
"version": "0.1.0",
"description": "Wallet-session plugin for SHiNE with session-only login via trusted device.",
"permissions": [
"storage"
"storage",
"sidePanel"
],
"host_permissions": [
"<all_urls>"
@@ -13,8 +14,32 @@
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content-script.js"
],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": [
"provider-bridge.js",
"js/lib/vendor/solana-publickey-bundle.js"
],
"matches": [
"<all_urls>"
]
}
],
"action": {
"default_title": "SHiNE Wallet",
"default_popup": "popup.html"
"default_title": "Open SHiNE Wallet"
},
"side_panel": {
"default_path": "popup.html"
}
}
+58 -4
View File
@@ -2,22 +2,34 @@
box-sizing: border-box;
}
html {
min-height: 100%;
}
body {
margin: 0;
min-width: 360px;
min-width: 320px;
max-width: none;
min-height: 100vh;
background: #0f1720;
color: #e8eef6;
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.side-panel-body {
width: 100%;
}
.layout {
padding: 12px;
min-height: 100vh;
}
.panel {
display: flex;
flex-direction: column;
gap: 12px;
min-height: calc(100vh - 24px);
}
.panel-header {
@@ -140,9 +152,9 @@ select {
}
.code {
font-size: 34px;
font-size: 30px;
font-weight: 700;
letter-spacing: 0.18em;
letter-spacing: 0.12em;
}
.summary-row {
@@ -153,7 +165,7 @@ select {
}
.summary-row code {
max-width: 180px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -186,6 +198,12 @@ select {
gap: 8px;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-row {
padding: 8px 10px;
border: 1px solid #243446;
@@ -193,6 +211,30 @@ select {
background: #0d141d;
}
.detail-row {
display: grid;
gap: 4px;
padding: 8px 10px;
border: 1px solid #243446;
border-radius: 8px;
background: #0d141d;
}
.detail-label {
font-size: 12px;
color: #9aabbd;
}
.detail-value {
color: #e8eef6;
word-break: break-word;
}
.detail-value.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #bed5f5;
}
.device-state {
font-size: 12px;
text-transform: lowercase;
@@ -209,3 +251,15 @@ select {
.device-state-unknown {
color: #f8e2a0;
}
.wallet-pubkey {
display: block;
width: 100%;
padding: 10px 12px;
border: 1px solid #314459;
border-radius: 8px;
background: #0d141d;
color: #bed5f5;
white-space: normal;
line-break: anywhere;
}
+53 -50
View File
@@ -6,7 +6,7 @@
<title>SHiNE Wallet</title>
<link rel="stylesheet" href="./popup.css" />
</head>
<body>
<body class="side-panel-body">
<main class="layout">
<section class="panel">
<div class="panel-header">
@@ -14,49 +14,13 @@
<h1>SHiNE Wallet</h1>
<p class="muted">Session-only wallet plugin</p>
</div>
<span id="connection-pill" class="pill pill-offline">offline</span>
<span id="connection-pill" class="pill pill-offline">не подключено</span>
</div>
<p id="server-login-info" class="muted small">Сервер SHiNE: —</p>
<p id="server-address" class="muted small">Адрес: —</p>
<div id="session-card" class="card hidden">
<div class="card-title">Подключённая wallet-session</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="summary-row"><span>Session ID</span><code id="session-id"></code></div>
<div class="summary-row"><span>Тип</span><strong id="session-type">wallet</strong></div>
<div class="summary-row"><span>deviceKey</span><code id="device-key-short"></code></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="refresh-devices-btn" class="btn secondary" type="button">Обновить устройства</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div id="signing-card" class="card hidden">
<div class="card-title">Подготовка подписи</div>
<label class="field">
<span>Ключ подписи</span>
<select id="sign-key-select"></select>
</label>
<label class="field">
<span>Устройство homeserver</span>
<select id="device-select"></select>
</label>
<div id="homeserver-list" class="device-list"></div>
<p class="muted small">
Для выбора доступны homeserver-сессии, опубликованные в PDA аккаунта. Online-статус определяется без постоянного удержания соединения.
</p>
<div class="actions">
<button id="prepare-sign-btn" class="btn primary" type="button">Запросить подпись</button>
</div>
<p class="muted small">
Сам signaling подтверждения подписи ещё не доделан. Сейчас доступен только каркас выбора ключа и устройства.
</p>
</div>
<div class="card">
<div class="card-title">Войти через другое устройство</div>
<div id="connect-card" class="card">
<div class="card-title">Подключение</div>
<label class="field">
<span>Логин</span>
<input id="login-input" type="text" autocomplete="username" />
@@ -69,29 +33,68 @@
<span>Пароль подключения</span>
<input id="password-input" type="password" autocomplete="current-password" />
</label>
<button id="start-btn" class="btn primary" type="button">Получить код</button>
<p class="muted small">
Wallet plugin создаёт временный requester keypair, ждёт подтверждение на доверенном устройстве
и получает только wallet-session без передачи постоянных ключей.
</p>
<button id="start-btn" class="btn primary" type="button">Подключить</button>
</div>
<div id="pairing-card" class="card hidden">
<div class="card-title">Код подключения</div>
<div id="short-code" class="code">0000000</div>
<p id="pairing-hint" class="muted small">
Покажите код на доверенном устройстве в разделе «Подключить по коду».
</p>
<div id="short-code" class="code">00 00 00 00 00</div>
<p id="pairing-hint" class="muted small">Покажите код на доверенном устройстве.</p>
<p id="pairing-expire" class="muted small"></p>
<div class="actions">
<button id="cancel-btn" class="btn secondary" type="button">Отменить</button>
</div>
</div>
<div id="session-card" class="card hidden">
<div class="card-title">Подключено</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div id="wallet-card" class="card hidden">
<div class="card-title">Текущий кошелёк ESP32</div>
<label class="field">
<span>Homeserver</span>
<select id="device-select"></select>
</label>
<div id="homeserver-list" class="device-list"></div>
<div class="actions">
<button id="refresh-devices-btn" class="btn secondary" type="button">Обновить устройства</button>
<button id="request-wallet-btn" class="btn primary" type="button">Запросить кошелёк</button>
</div>
</div>
<div id="pending-approval-card" class="card hidden">
<div class="card-title">Ожидается подпись</div>
<p id="pending-approval-subtitle" class="muted small">Сайт запросил подписание транзакции.</p>
<div id="pending-approval-details" class="device-list"></div>
<p class="muted small">Запрос уже отправлен на доверенное устройство. Здесь можно только отменить ожидание.</p>
<div class="actions">
<button id="cancel-pending-approval-btn" class="btn danger" type="button">Отменить</button>
</div>
</div>
<div id="wallet-result-card" class="card hidden">
<div class="card-title">Полученный кошелёк</div>
<div class="summary-row"><span>Тип</span><strong id="wallet-type"></strong></div>
<div class="field">
<span>Public key</span>
<code id="wallet-pubkey" class="wallet-pubkey"></code>
</div>
<p id="wallet-verify" class="muted small"></p>
<div class="actions">
<button id="copy-wallet-btn" class="btn secondary" type="button">Копировать ключ</button>
</div>
</div>
<div id="status" class="status hidden"></div>
</section>
</main>
<script type="module" src="./popup.js"></script>
</body>
</html>
</html>
+132 -67
View File
@@ -1,11 +1,13 @@
import { formatPairingShortCode } from './js/lib/device-pairing.js';
const els = {
serverLoginInfo: document.querySelector('#server-login-info'),
serverAddress: document.querySelector('#server-address'),
loginInput: document.querySelector('#login-input'),
usePassword: document.querySelector('#use-password'),
passwordField: document.querySelector('#password-field'),
passwordInput: document.querySelector('#password-input'),
startBtn: document.querySelector('#start-btn'),
connectCard: document.querySelector('#connect-card'),
pairingCard: document.querySelector('#pairing-card'),
shortCode: document.querySelector('#short-code'),
pairingHint: document.querySelector('#pairing-hint'),
@@ -14,17 +16,22 @@ const els = {
status: document.querySelector('#status'),
sessionCard: document.querySelector('#session-card'),
sessionLogin: document.querySelector('#session-login'),
sessionId: document.querySelector('#session-id'),
sessionType: document.querySelector('#session-type'),
deviceKeyShort: document.querySelector('#device-key-short'),
resumeBtn: document.querySelector('#resume-btn'),
refreshDevicesBtn: document.querySelector('#refresh-devices-btn'),
disconnectBtn: document.querySelector('#disconnect-btn'),
signingCard: document.querySelector('#signing-card'),
signKeySelect: document.querySelector('#sign-key-select'),
walletCard: document.querySelector('#wallet-card'),
deviceSelect: document.querySelector('#device-select'),
homeserverList: document.querySelector('#homeserver-list'),
prepareSignBtn: document.querySelector('#prepare-sign-btn'),
requestWalletBtn: document.querySelector('#request-wallet-btn'),
pendingApprovalCard: document.querySelector('#pending-approval-card'),
pendingApprovalSubtitle: document.querySelector('#pending-approval-subtitle'),
pendingApprovalDetails: document.querySelector('#pending-approval-details'),
cancelPendingApprovalBtn: document.querySelector('#cancel-pending-approval-btn'),
walletResultCard: document.querySelector('#wallet-result-card'),
walletType: document.querySelector('#wallet-type'),
walletPubkey: document.querySelector('#wallet-pubkey'),
walletVerify: document.querySelector('#wallet-verify'),
copyWalletBtn: document.querySelector('#copy-wallet-btn'),
connectionPill: document.querySelector('#connection-pill'),
};
@@ -32,16 +39,20 @@ let state = {
settings: {
serverLogin: 'shineupme',
serverHttp: 'https://shineup.me',
serverUrl: 'wss://shineup.me/ws',
login: '',
},
pairing: {
active: false,
pairingId: '',
expiresAtMs: 0,
shortCode: '',
},
session: null,
connectionOnline: false,
walletProfile: null,
signing: {
selectedDeviceName: '',
},
currentWallet: null,
pendingApproval: null,
status: {
text: '',
kind: 'info',
@@ -58,7 +69,7 @@ function setStatus(message, kind = 'info') {
}
function setConnectedPill(connected) {
els.connectionPill.textContent = connected ? 'online' : 'offline';
els.connectionPill.textContent = connected ? 'подключено' : 'не подключено';
els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline';
}
@@ -97,81 +108,119 @@ function renderHomeserverList(items = []) {
});
}
function renderPendingApproval(pendingApproval) {
els.pendingApprovalDetails.innerHTML = '';
if (!pendingApproval) return;
const summary = pendingApproval.transactionSummary || {};
const programs = Array.isArray(summary.programs) && summary.programs.length
? summary.programs.join(', ')
: 'не определены';
const details = [
{ label: 'Сайт', value: pendingApproval.origin || '—', mono: true },
{ label: 'Кошелёк', value: pendingApproval.publicKeyBase58 || '—', mono: true },
{ label: 'Очередь', value: `${pendingApproval.queuePosition || 1} из ${pendingApproval.queueLength || 1}` },
{ label: 'Комментарий', value: pendingApproval.comment || 'Транзакция запрошена сайтом' },
{ label: 'Тип', value: summary.kind || 'legacy' },
{ label: 'Инструкций', value: String(summary.instructionCount ?? 0) },
{ label: 'Программы', value: programs, mono: true },
];
if (summary.feePayer) {
details.push({ label: 'Fee payer', value: summary.feePayer, mono: true });
}
if (summary.recentBlockhash) {
details.push({ label: 'Blockhash', value: summary.recentBlockhash, mono: true });
}
for (const item of details) {
const row = document.createElement('div');
row.className = 'detail-row';
const label = document.createElement('div');
label.className = 'detail-label';
label.textContent = item.label;
const value = document.createElement('div');
value.className = `detail-value${item.mono ? ' mono' : ''}`;
value.textContent = item.value;
row.append(label, value);
els.pendingApprovalDetails.append(row);
}
}
function applyState(nextState) {
state = nextState || state;
const loginValue = String(state?.settings?.login || '');
const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim();
const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim();
if (loginValue && resolvedServerLogin && resolvedServerAddress) {
els.serverLoginInfo.textContent = `Сервер SHiNE: ${resolvedServerLogin}`;
els.serverAddress.textContent = `Адрес: ${resolvedServerAddress}`;
} else {
els.serverLoginInfo.textContent = 'Сервер SHiNE: —';
els.serverAddress.textContent = 'Адрес: —';
}
els.serverLoginInfo.textContent = resolvedServerLogin && resolvedServerAddress
? `Сервер SHiNE: ${resolvedServerLogin} (${resolvedServerAddress})`
: 'Сервер SHiNE: —';
if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue;
}
setConnectedPill(!!state?.connectionOnline);
setConnectedPill(!!state?.session);
setStatus(state?.status?.text || '', state?.status?.kind || 'info');
const session = state?.session;
const walletProfile = state?.walletProfile;
const signing = state?.signing || {};
if (session) {
els.sessionCard.classList.remove('hidden');
els.sessionLogin.textContent = session.login || '—';
els.sessionId.textContent = session.sessionId || '—';
els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—');
els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || '');
els.signingCard.classList.remove('hidden');
} else {
els.sessionCard.classList.add('hidden');
els.sessionLogin.textContent = '—';
els.sessionId.textContent = '—';
els.sessionType.textContent = 'wallet';
els.deviceKeyShort.textContent = '—';
els.signingCard.classList.add('hidden');
}
const currentWallet = state?.currentWallet || null;
const pendingApproval = state?.pendingApproval || null;
const signKeyOptions = Array.isArray(walletProfile?.signingKeyOptions) ? walletProfile.signingKeyOptions : [];
els.signKeySelect.innerHTML = '';
signKeyOptions.forEach((item) => {
const option = document.createElement('option');
option.value = item.id;
option.textContent = item.label;
option.selected = item.id === signing.selectedKeyId;
els.signKeySelect.append(option);
});
els.connectCard.classList.toggle('hidden', !!session);
els.sessionCard.classList.toggle('hidden', !session);
els.walletCard.classList.toggle('hidden', !session);
els.pendingApprovalCard.classList.toggle('hidden', !pendingApproval);
if (session) {
els.sessionLogin.textContent = session.login || '—';
}
const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : [];
els.deviceSelect.innerHTML = '';
homeservers.forEach((item) => {
const option = document.createElement('option');
option.value = item.sessionName;
option.textContent = `${item.sessionName} [${item.onlineState || 'unknown'}]`;
option.textContent = `${item.sessionName} [${item.onlineState || 'offline'}]`;
option.selected = item.sessionName === signing.selectedDeviceName;
els.deviceSelect.append(option);
});
renderHomeserverList(homeservers);
els.prepareSignBtn.disabled = !session || !signing.selectedKeyId || !signing.selectedDeviceName;
els.requestWalletBtn.disabled = !session || !signing.selectedDeviceName;
if (pendingApproval) {
const queueSuffix = (pendingApproval.queueLength || 1) > 1
? ` В очереди ${pendingApproval.queueLength} транзакции.`
: '';
els.pendingApprovalSubtitle.textContent = pendingApproval.origin
? `Сайт ${pendingApproval.origin} запросил подписание транзакции.${queueSuffix}`
: `Сайт запросил подписание транзакции.${queueSuffix}`;
renderPendingApproval(pendingApproval);
} else {
els.pendingApprovalSubtitle.textContent = 'Сайт запросил подписание транзакции.';
els.pendingApprovalDetails.innerHTML = '';
}
if (currentWallet?.publicKeyBase58) {
els.walletResultCard.classList.remove('hidden');
els.walletType.textContent = currentWallet.type || '—';
els.walletPubkey.textContent = currentWallet.publicKeyBase58 || '—';
els.walletVerify.textContent = currentWallet.verificationText || '—';
} else {
els.walletResultCard.classList.add('hidden');
els.walletType.textContent = '—';
els.walletPubkey.textContent = '—';
els.walletVerify.textContent = '—';
}
const pairing = state?.pairing || {};
if (pairing.active) {
els.pairingCard.classList.remove('hidden');
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || '0000000');
els.shortCode.dataset.shortCode = shortCode;
els.shortCode.textContent = shortCode;
els.pairingHint.textContent = pairing.trustedSessionOnline
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
els.shortCode.textContent = formatPairingShortCode(String(pairing.shortCode || ''));
const leftMs = Number(pairing.expiresAtMs || 0) - Date.now();
els.pairingExpire.textContent = leftMs > 0 ? `Код действителен ещё ${formatRemaining(leftMs)}.` : 'Время ожидания истекло.';
els.startBtn.disabled = true;
} else {
els.pairingCard.classList.add('hidden');
els.shortCode.textContent = '0000000';
delete els.shortCode.dataset.shortCode;
els.shortCode.textContent = formatPairingShortCode('');
els.pairingExpire.textContent = '';
els.startBtn.disabled = false;
}
@@ -239,16 +288,13 @@ async function startPairing() {
return;
}
setStatus('Создаём wallet-session заявку...', 'info');
els.startBtn.disabled = true;
try {
const response = await sendMessage('wallet:startPairing', {
await sendMessage('wallet:startPairing', {
login,
usePassword: !!els.usePassword.checked,
password: String(els.passwordInput.value || ''),
});
applyState(response.state);
} catch (error) {
els.startBtn.disabled = false;
setStatus(error.message || 'Не удалось начать pairing.', 'error');
}
}
@@ -287,23 +333,41 @@ async function refreshDevices() {
}
}
async function updateSigningSelection() {
async function updateDeviceSelection() {
try {
await sendMessage('wallet:updateSigningSelection', {
selectedKeyId: String(els.signKeySelect.value || '').trim(),
selectedDeviceName: String(els.deviceSelect.value || '').trim(),
});
} catch (error) {
setStatus(error.message || 'Не удалось обновить выбор для подписи.', 'error');
setStatus(error.message || 'Не удалось обновить выбор homeserver.', 'error');
}
}
async function prepareSignSignal() {
setStatus('Готовим каркас запроса подписи...', 'info');
async function requestCurrentWallet() {
setStatus('Запрашиваем текущий кошелёк с ESP32...', 'info');
try {
await sendMessage('wallet:prepareSignSignal');
await sendMessage('wallet:requestCurrentWallet');
} catch (error) {
setStatus(error.message || 'Не удалось подготовить запрос подписи.', 'error');
setStatus(error.message || 'Не удалось получить кошелёк с ESP32.', 'error');
}
}
async function copyWalletKey() {
const value = String(els.walletPubkey.textContent || '').trim();
if (!value || value === '—') return;
try {
await navigator.clipboard.writeText(value);
setStatus('Публичный ключ скопирован.', 'info');
} catch (error) {
setStatus(error.message || 'Не удалось скопировать ключ.', 'error');
}
}
async function cancelPendingApproval() {
try {
await sendMessage('wallet:cancelPendingSiteApproval');
} catch (error) {
setStatus(error.message || 'Не удалось отменить ожидание подписи.', 'error');
}
}
@@ -338,9 +402,10 @@ function bindUi() {
els.resumeBtn.addEventListener('click', () => { void resumeSession(); });
els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); });
els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); });
els.signKeySelect.addEventListener('change', () => { void updateSigningSelection(); });
els.deviceSelect.addEventListener('change', () => { void updateSigningSelection(); });
els.prepareSignBtn.addEventListener('click', () => { void prepareSignSignal(); });
els.deviceSelect.addEventListener('change', () => { void updateDeviceSelection(); });
els.requestWalletBtn.addEventListener('click', () => { void requestCurrentWallet(); });
els.copyWalletBtn.addEventListener('click', () => { void copyWalletKey(); });
els.cancelPendingApprovalBtn.addEventListener('click', () => { void cancelPendingApproval(); });
}
async function init() {
@@ -0,0 +1,433 @@
import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/solana-publickey-bundle.js';
const PAGE_REQUEST = 'shine-wallet-page-request';
const PAGE_RESPONSE = 'shine-wallet-page-response';
const PAGE_MESSAGE_TARGET_ORIGIN = '*';
const STANDARD_REGISTER_EVENT = 'wallet-standard:register-wallet';
const STANDARD_APP_READY_EVENT = 'wallet-standard:app-ready';
const SOLANA_CHAINS = ['solana:mainnet', 'solana:devnet', 'solana:testnet'];
const SOLANA_STANDARD_FEATURES = ['solana:signTransaction'];
const WALLET_ICON = `data:image/svg+xml;base64,${btoa(
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"><rect width="96" height="96" rx="20" fill="#101722"/><path d="M23 28h50l-8 10H31l-8-10Z" fill="#3FB0FF"/><path d="M31 44h34l8 10H23l8-10Z" fill="#77D67A"/><path d="M23 68h50l-8-10H31l-8 10Z" fill="#A881FF"/></svg>'
)}`;
function bytesToBase64(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode(...slice);
}
return btoa(binary);
}
function base64ToBytes(value) {
const binary = atob(String(value || '').trim());
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
function createProviderError(message, code = '') {
const error = new Error(String(message || 'Wallet provider error'));
if (code === 'USER_REJECTED' || code === 'NOT_TRUSTED') {
error.code = 4001;
} else if (code) {
error.code = code;
}
return error;
}
function summarizeTransaction(transaction) {
const summary = {
kind: 'legacy',
instructionCount: 0,
accountCount: 0,
feePayer: '',
recentBlockhash: '',
programs: [],
};
if (!transaction) return summary;
const isVersioned = typeof transaction?.version === 'number' || transaction instanceof VersionedTransaction;
summary.kind = isVersioned ? `versioned:${String(transaction.version)}` : 'legacy';
summary.feePayer = String(transaction?.feePayer?.toBase58?.() || '').trim();
summary.recentBlockhash = String(transaction?.recentBlockhash || transaction?.message?.recentBlockhash || '').trim();
if (isVersioned) {
const message = transaction?.message || {};
const staticKeys = Array.isArray(message?.staticAccountKeys) ? message.staticAccountKeys : [];
const instructions = Array.isArray(message?.compiledInstructions) ? message.compiledInstructions : [];
summary.instructionCount = instructions.length;
summary.accountCount = staticKeys.length;
summary.programs = instructions
.map((instruction) => staticKeys[instruction?.programIdIndex]?.toBase58?.() || '')
.filter(Boolean)
.slice(0, 5);
return summary;
}
const instructions = Array.isArray(transaction?.instructions) ? transaction.instructions : [];
summary.instructionCount = instructions.length;
summary.accountCount = Array.isArray(transaction?.signatures) ? transaction.signatures.length : 0;
summary.programs = instructions
.map((instruction) => instruction?.programId?.toBase58?.() || '')
.filter(Boolean)
.slice(0, 5);
return summary;
}
function createRequest(method, params = {}) {
const id = `shine-wallet-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
return new Promise((resolve, reject) => {
const onMessage = (event) => {
if (event.source !== window) return;
const data = event.data || {};
if (data?.target !== PAGE_RESPONSE || String(data?.id || '') !== id) return;
window.removeEventListener('message', onMessage);
if (!data?.ok) {
reject(createProviderError(data?.error || 'Wallet request failed', String(data?.code || '')));
return;
}
resolve(data?.result || {});
};
window.addEventListener('message', onMessage);
window.postMessage({
target: PAGE_REQUEST,
id,
method,
params,
}, PAGE_MESSAGE_TARGET_ORIGIN);
});
}
function serializeTransactionBase64(transaction) {
if (!transaction || typeof transaction.serialize !== 'function') {
throw createProviderError('Unsupported transaction object', 'UNSUPPORTED_TRANSACTION');
}
let raw;
try {
raw = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
} catch {
raw = transaction.serialize();
}
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
return bytesToBase64(bytes);
}
function deserializeSignedTransaction(base64, originalTransaction) {
const bytes = base64ToBytes(base64);
const ctor = originalTransaction?.constructor;
if (ctor && typeof ctor.deserialize === 'function') {
return ctor.deserialize(bytes);
}
if (ctor && typeof ctor.from === 'function') {
return ctor.from(bytes);
}
if (typeof originalTransaction?.version === 'number') {
return VersionedTransaction.deserialize(bytes);
}
return Transaction.from(bytes);
}
class ShineWalletAccount {
constructor(publicKeyBase58) {
this.address = publicKeyBase58;
this.publicKey = new Uint8Array(new PublicKey(publicKeyBase58).toBytes());
this.chains = SOLANA_CHAINS.slice();
this.features = SOLANA_STANDARD_FEATURES.slice();
this.label = 'SHiNE Wallet';
this.icon = WALLET_ICON;
}
}
class ShineProviderCore {
constructor() {
this.publicKey = null;
this.isConnected = false;
this._legacyListeners = new Map();
this._standardListeners = new Set();
this._accounts = [];
}
get publicKeyBase58() {
return this.publicKey?.toBase58?.() || '';
}
get standardAccounts() {
return this._accounts.slice();
}
async connect(options = {}) {
const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent;
const result = await createRequest('connect', { onlyIfTrusted });
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
this.publicKey = nextKey;
this.isConnected = true;
this._accounts = [new ShineWalletAccount(nextKey.toBase58())];
this.emitLegacy('connect', nextKey);
this.emitLegacy('accountChanged', nextKey);
this.emitStandardChange();
return {
publicKey: nextKey,
accounts: this.standardAccounts,
};
}
async disconnect() {
await createRequest('disconnect', {});
this.isConnected = false;
this.publicKey = null;
this._accounts = [];
this.emitLegacy('disconnect');
this.emitLegacy('accountChanged', null);
this.emitStandardChange();
}
async signTransaction(transaction, comment = '') {
if (!this.publicKey) {
await this.connect();
}
const transactionBase64 = serializeTransactionBase64(transaction);
const transactionSummary = summarizeTransaction(transaction);
const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKeyBase58,
transactionBase64,
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
transactionSummary,
});
return deserializeSignedTransaction(String(result?.signedTransactionBase64 || ''), transaction);
}
async signTransactionBytes(transactionBytes, comment = '') {
if (!this.publicKey) {
await this.connect();
}
const transactionSummary = {
kind: 'raw-bytes',
instructionCount: 0,
accountCount: 0,
feePayer: this.publicKeyBase58,
recentBlockhash: '',
programs: [],
byteLength: Number(transactionBytes?.length || 0),
};
const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKeyBase58,
transactionBase64: bytesToBase64(transactionBytes),
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
transactionSummary,
});
return base64ToBytes(String(result?.signedTransactionBase64 || '').trim());
}
onLegacy(event, handler) {
const key = String(event || '');
if (!this._legacyListeners.has(key)) {
this._legacyListeners.set(key, new Set());
}
this._legacyListeners.get(key).add(handler);
return this;
}
offLegacy(event, handler) {
const key = String(event || '');
const bucket = this._legacyListeners.get(key);
if (!bucket) return this;
bucket.delete(handler);
if (!bucket.size) this._legacyListeners.delete(key);
return this;
}
emitLegacy(event, payload) {
const bucket = this._legacyListeners.get(String(event || ''));
if (!bucket?.size) return;
for (const handler of [...bucket]) {
try {
handler(payload);
} catch {}
}
}
onStandardChange(listener) {
this._standardListeners.add(listener);
return () => {
this._standardListeners.delete(listener);
};
}
emitStandardChange() {
const properties = { accounts: this.standardAccounts };
for (const listener of [...this._standardListeners]) {
try {
listener(properties);
} catch {}
}
}
}
class ShineSolanaProvider {
constructor(core) {
this.core = core;
this.isSHiNE = true;
this.isPhantom = true;
}
get publicKey() {
return this.core.publicKey;
}
get isConnected() {
return this.core.isConnected;
}
on(event, handler) {
return this.core.onLegacy(event, handler);
}
off(event, handler) {
return this.core.offLegacy(event, handler);
}
removeListener(event, handler) {
return this.off(event, handler);
}
async connect(options = {}) {
const result = await this.core.connect(options);
return { publicKey: result.publicKey };
}
async disconnect() {
await this.core.disconnect();
}
async signTransaction(transaction) {
return this.core.signTransaction(transaction);
}
async signAllTransactions(transactions = []) {
const list = Array.isArray(transactions) ? transactions : [];
const outputs = [];
for (const transaction of list) {
outputs.push(await this.core.signTransaction(transaction));
}
return outputs;
}
async request(args = {}) {
const method = String(args?.method || '');
const params = args?.params;
if (method === 'connect') {
return this.connect(Array.isArray(params) ? params[0] : params || {});
}
if (method === 'disconnect') {
return this.disconnect();
}
if (method === 'signTransaction') {
const tx = Array.isArray(params) ? params[0] : params?.transaction || params;
return this.signTransaction(tx);
}
if (method === 'signAllTransactions') {
const transactions = Array.isArray(params)
? params
: Array.isArray(params?.transactions) ? params.transactions : [];
return this.signAllTransactions(transactions);
}
throw createProviderError(`Unsupported request method: ${method}`, 'UNSUPPORTED_METHOD');
}
}
class ShineStandardWallet {
constructor(core) {
this.core = core;
this.version = '1.0.0';
this.name = 'SHiNE Wallet';
this.icon = WALLET_ICON;
this.chains = SOLANA_CHAINS.slice();
this.features = {
'standard:connect': {
version: '1.0.0',
connect: async (input = {}) => {
const result = await this.core.connect({ silent: !!input?.silent });
return { accounts: result.accounts };
},
},
'standard:disconnect': {
version: '1.0.0',
disconnect: async () => {
await this.core.disconnect();
},
},
'standard:events': {
version: '1.0.0',
on: (event, listener) => {
if (event !== 'change' || typeof listener !== 'function') {
return () => {};
}
return this.core.onStandardChange(listener);
},
},
'solana:signTransaction': {
version: '1.0.0',
supportedTransactionVersions: ['legacy', 0],
signTransaction: async (...inputs) => {
const outputs = [];
for (const input of inputs) {
const accountAddress = String(input?.account?.address || '').trim();
if (accountAddress && this.core.publicKeyBase58 && accountAddress !== this.core.publicKeyBase58) {
throw createProviderError('Requested account does not match current wallet account', 'ACCOUNT_MISMATCH');
}
const comment = `Site ${window.location.origin} requested transaction signature`;
const signedTransaction = await this.core.signTransactionBytes(new Uint8Array(input.transaction), comment);
outputs.push({ signedTransaction });
}
return outputs;
},
},
};
}
get accounts() {
return this.core.standardAccounts;
}
}
function registerStandardWallet(wallet) {
const callback = ({ register }) => register(wallet);
try {
window.dispatchEvent(new CustomEvent(STANDARD_REGISTER_EVENT, { detail: callback }));
} catch (error) {
console.error('wallet-standard register dispatch failed', error);
}
try {
window.addEventListener(STANDARD_APP_READY_EVENT, ({ detail }) => {
try {
callback(detail);
} catch (error) {
console.error('wallet-standard app-ready callback failed', error);
}
});
} catch (error) {
console.error('wallet-standard app-ready listener failed', error);
}
try {
window.navigator.wallets = window.navigator.wallets || [];
window.navigator.wallets.push(callback);
} catch {}
}
const core = new ShineProviderCore();
const legacyProvider = new ShineSolanaProvider(core);
const standardWallet = new ShineStandardWallet(core);
registerStandardWallet(standardWallet);
if (!window.solana) {
window.solana = legacyProvider;
window.phantom = window.phantom || {};
window.phantom.solana = legacyProvider;
window.dispatchEvent(new Event('solana#initialized'));
}
+22 -3
View File
@@ -42,8 +42,8 @@ shine-UI/server-ui.html
Для обновления — только root + device (blockchain-ключ не нужен).
Актуальные адреса программ Solana (devnet):
- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
- `shine_payments`: `SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v`
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
@@ -56,9 +56,28 @@ shine-UI/server-ui.html
```
./gradlew deployServer
./gradlew deployUI
```
Хост по умолчанию: `player@93.170.12.154` (shineup.me).
Default deploy по умолчанию идёт на `t.shineup.me` (`player@193.8.215.70`).
Production deploy:
```
./gradlew deployServerProduction
./gradlew deployUIProduction
```
Любые изменения на `shineup.me` делать только после отдельного явного подтверждения пользователя.
Резервный test-контур:
```
./gradlew deployServerTest
./gradlew deployUITest
```
`test.shineup.me` считается резервным тестовым сервером и в обычный deploy не включается.
Логи на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`
File diff suppressed because it is too large Load Diff
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
@@ -9,8 +9,10 @@ import java.util.Objects;
* FileStoreUtil утилита работы с файлами в папке data/.
*
* Теперь поддерживает:
* - основной файл блокчейна: <blockchainName>.bch
* - временный файл блокчейна: <blockchainName>.tmp_bch
* - основной файл блокчейна: <blockchainName>.bch
* - временный файл блокчейна: <blockchainName>.tmp_bch
* - sidecar-файл проверки записи: <blockchainName>.write_check
* - marker-файл записи: <blockchainName>.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);
}
/** <blockchainName>.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));
}
/** <blockchainName>.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]);
}
/** <blockchainName>.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));
}
/**
* Атомарно заменить основной файл блокчейна временным:
* <name>.tmp_bch -> <name>.bch
@@ -202,4 +294,4 @@ public final class FileStoreUtil {
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
}
}
}
}
@@ -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, если параметр не найден */
@@ -1,21 +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 = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
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();
}
}
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
File diff suppressed because it is too large Load Diff
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
@@ -140,7 +140,7 @@ public final class DatabaseInitializer {
// 1. solana_users
// ВАЖНО:
// - Все требуемые поля теперь лежат в solana_users:
// login, blockchain_name, solana_key, blockchain_key, device_key
// login, blockchain_name, solana_key, blockchain_key, client_key
// - Поиск по login в DAO сделан case-insensitive.
// - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY.
st.executeUpdate("""
@@ -149,7 +149,7 @@ public final class DatabaseInitializer {
blockchain_name TEXT NOT NULL,
solana_key TEXT NOT NULL,
blockchain_key TEXT NOT NULL,
device_key TEXT NOT NULL
client_key TEXT NOT NULL
);
""");
@@ -238,7 +238,7 @@ public final class DatabaseInitializer {
param TEXT NOT NULL,
time_ms INTEGER NOT NULL,
value TEXT NOT NULL,
device_key TEXT,
client_key TEXT,
signature TEXT,
FOREIGN KEY (login) REFERENCES solana_users(login),
UNIQUE (login, param)
@@ -264,6 +264,21 @@ public final class DatabaseInitializer {
ON ip_geo_cache (updated_at_ms);
""");
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
used_count INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL,
last_tx_id TEXT NOT NULL DEFAULT '',
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
ON test_free_avatar_uploads (updated_at_ms);
""");
// 5. blockchain_state
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS blockchain_state (
@@ -607,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,
@@ -619,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,
@@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = 7;
private static final int LATEST_SCHEMA_VERSION = 11;
private final String jdbcUrl;
@@ -90,6 +90,10 @@ public final class SqliteDbController {
case 5 -> migrateToV5();
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);
}
}
@@ -249,6 +253,82 @@ public final class SqliteDbController {
}
}
private void migrateToV8() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureTestFreeAvatarUploadsTable(st);
setSchemaVersion(c, 8);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v8 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v8 failed", e);
}
}
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 (
@@ -376,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");
@@ -432,6 +534,36 @@ public final class SqliteDbController {
""");
}
private static void ensureTestFreeAvatarUploadsTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS test_free_avatar_uploads (
login TEXT NOT NULL PRIMARY KEY COLLATE NOCASE,
used_count INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL,
last_tx_id TEXT NOT NULL DEFAULT '',
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_test_free_avatar_uploads_updated
ON test_free_avatar_uploads (updated_at_ms);
""");
}
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("""
@@ -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
) {}
}
@@ -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<BlockchainStateEntry> listAll() throws SQLException {
try (Connection c = db.getConnection()) {
return listAll(c);
}
}
/** Получить все blockchain_state записи с внешним соединением. Соединение НЕ закрывает. */
public List<BlockchainStateEntry> 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<BlockchainStateEntry> 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; }
}
}
@@ -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<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
try (Connection c = db.getConnection()) {
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
}
}
public List<BlockEntry> 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<BlockEntry> 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;
}
}
}
@@ -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<SignedMessageV2Entry> 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());
}
}
}
@@ -17,7 +17,7 @@ import java.util.List;
* - blockchain_name TEXT NOT NULL
* - solana_key TEXT NOT NULL
* - blockchain_key TEXT NOT NULL
* - device_key TEXT NOT NULL
* - client_key TEXT NOT NULL
*
* Правило работы с соединениями:
* - методы с Connection НЕ закрывают соединение
@@ -45,7 +45,7 @@ public final class SolanaUsersDAO {
public void insert(Connection c, SolanaUserEntry user) throws SQLException {
String sql = """
INSERT INTO solana_users (
login, blockchain_name, solana_key, blockchain_key, device_key
login, blockchain_name, solana_key, blockchain_key, client_key
) VALUES (?, ?, ?, ?, ?)
""";
@@ -54,7 +54,7 @@ public final class SolanaUsersDAO {
ps.setString(2, user.getBlockchainName());
ps.setString(3, user.getSolanaKey());
ps.setString(4, user.getBlockchainKey());
ps.setString(5, user.getDeviceKey());
ps.setString(5, user.getClientKey());
ps.executeUpdate();
}
}
@@ -126,7 +126,7 @@ public final class SolanaUsersDAO {
blockchain_name,
solana_key,
blockchain_key,
device_key
client_key
FROM solana_users
WHERE LOWER(login) = LOWER(?)
""";
@@ -155,7 +155,7 @@ public final class SolanaUsersDAO {
blockchain_name,
solana_key,
blockchain_key,
device_key
client_key
FROM solana_users
WHERE blockchain_name = ?
""";
@@ -184,7 +184,7 @@ public final class SolanaUsersDAO {
blockchain_name,
solana_key,
blockchain_key,
device_key
client_key
FROM solana_users
WHERE LOWER(login) LIKE ?
ORDER BY login
@@ -219,7 +219,7 @@ public final class SolanaUsersDAO {
e.setBlockchainName(rs.getString("blockchain_name"));
e.setSolanaKey(rs.getString("solana_key"));
e.setBlockchainKey(rs.getString("blockchain_key"));
e.setDeviceKey(rs.getString("device_key"));
e.setClientKey(rs.getString("client_key"));
return e;
}
@@ -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<SyncServerEntry> listAll() throws SQLException {
try (Connection c = db.getConnection()) {
return listAll(c);
}
}
public List<SyncServerEntry> listAll(Connection c) throws SQLException {
String sql = """
SELECT login, server_address, updated_at_ms
FROM sync_servers
ORDER BY login
""";
List<SyncServerEntry> 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<SyncServerEntry> entries) throws SQLException {
try (Connection c = db.getConnection()) {
replaceAll(c, entries);
}
}
public void replaceAll(Connection c, List<SyncServerEntry> 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;
}
}
@@ -0,0 +1,82 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.TestFreeAvatarUploadEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public final class TestFreeAvatarUploadsDAO {
private static volatile TestFreeAvatarUploadsDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private TestFreeAvatarUploadsDAO() {
}
public static TestFreeAvatarUploadsDAO getInstance() {
if (instance == null) {
synchronized (TestFreeAvatarUploadsDAO.class) {
if (instance == null) instance = new TestFreeAvatarUploadsDAO();
}
}
return instance;
}
public TestFreeAvatarUploadEntry getByLogin(Connection c, String login) throws SQLException {
String sql = """
SELECT login, used_count, updated_at_ms, last_tx_id
FROM test_free_avatar_uploads
WHERE login = ? COLLATE NOCASE
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public TestFreeAvatarUploadEntry getByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getByLogin(c, login);
}
}
public void upsertUsage(Connection c, String login, int usedCount, long updatedAtMs, String lastTxId) throws SQLException {
String sql = """
INSERT INTO test_free_avatar_uploads (login, used_count, updated_at_ms, last_tx_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(login) DO UPDATE SET
used_count = excluded.used_count,
updated_at_ms = excluded.updated_at_ms,
last_tx_id = excluded.last_tx_id
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, usedCount);
ps.setLong(3, updatedAtMs);
ps.setString(4, lastTxId);
ps.executeUpdate();
}
}
public void upsertUsage(String login, int usedCount, long updatedAtMs, String lastTxId) throws SQLException {
try (Connection c = db.getConnection()) {
upsertUsage(c, login, usedCount, updatedAtMs, lastTxId);
}
}
private static TestFreeAvatarUploadEntry mapRow(ResultSet rs) throws SQLException {
return new TestFreeAvatarUploadEntry(
rs.getString("login"),
rs.getInt("used_count"),
rs.getLong("updated_at_ms"),
rs.getString("last_tx_id")
);
}
}
@@ -7,7 +7,7 @@ import java.sql.*;
/**
* UserCreateDAO атомарное добавление пользователя:
* - solana_users (login, blockchain_name, solana_key, blockchain_key, device_key)
* - solana_users (login, blockchain_name, solana_key, blockchain_key, client_key)
* - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...)
*
* ВАЖНО:
@@ -39,7 +39,7 @@ public final class UserCreateDAO {
String blockchainName,
String solanaKey,
String blockchainKey,
String deviceKey,
String clientKey,
long sizeLimit,
long nowMs
) throws SQLException {
@@ -55,7 +55,7 @@ public final class UserCreateDAO {
u.setBlockchainName(blockchainName);
u.setSolanaKey(solanaKey);
u.setBlockchainKey(blockchainKey);
u.setDeviceKey(deviceKey);
u.setClientKey(clientKey);
usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint
@@ -43,14 +43,14 @@ public final class UserParamsDAO {
param,
time_ms,
value,
device_key,
client_key,
signature
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(login, param)
DO UPDATE SET
time_ms = excluded.time_ms,
value = excluded.value,
device_key = excluded.device_key,
client_key = excluded.client_key,
signature = excluded.signature
WHERE users_params.time_ms < excluded.time_ms
""";
@@ -61,7 +61,7 @@ public final class UserParamsDAO {
ps.setLong(3, e.getTimeMs());
ps.setString(4, e.getValue());
if (e.getDeviceKey() != null) ps.setString(5, e.getDeviceKey());
if (e.getClientKey() != null) ps.setString(5, e.getClientKey());
else ps.setNull(5, Types.VARCHAR);
if (e.getSignature() != null) ps.setString(6, e.getSignature());
@@ -86,7 +86,7 @@ public final class UserParamsDAO {
param,
time_ms,
value,
device_key,
client_key,
signature
FROM users_params
WHERE login = ? COLLATE NOCASE AND param = ?
@@ -117,7 +117,7 @@ public final class UserParamsDAO {
param,
time_ms,
value,
device_key,
client_key,
signature
FROM users_params
WHERE login = ? COLLATE NOCASE
@@ -149,9 +149,9 @@ public final class UserParamsDAO {
e.setTimeMs(rs.getLong("time_ms"));
e.setValue(rs.getString("value"));
String dk = rs.getString("device_key");
String dk = rs.getString("client_key");
if (rs.wasNull()) dk = null;
e.setDeviceKey(dk);
e.setClientKey(dk);
String sig = rs.getString("signature");
if (rs.wasNull()) sig = null;
@@ -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; }
@@ -12,7 +12,7 @@ import java.util.Base64;
* - blockchain_name TEXT NOT NULL
* - solana_key TEXT NOT NULL
* - blockchain_key TEXT NOT NULL
* - device_key TEXT NOT NULL
* - client_key TEXT NOT NULL
*/
public class SolanaUserEntry {
@@ -27,7 +27,7 @@ public class SolanaUserEntry {
private String blockchainKey;
/** Ключ устройства (публичный ключ устройства) */
private String deviceKey;
private String clientKey;
public SolanaUserEntry() {}
@@ -35,12 +35,12 @@ public class SolanaUserEntry {
String blockchainName,
String solanaKey,
String blockchainKey,
String deviceKey) {
String clientKey) {
this.login = login;
this.blockchainName = blockchainName;
this.solanaKey = solanaKey;
this.blockchainKey = blockchainKey;
this.deviceKey = deviceKey;
this.clientKey = clientKey;
}
public String getLogin() { return login; }
@@ -55,13 +55,13 @@ public class SolanaUserEntry {
public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
public String getDeviceKey() { return deviceKey; }
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; }
public String getClientKey() { return clientKey; }
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
// оставляю этот метод как утилиту (иногда удобно), но он работает только для deviceKey:
public byte[] getDeviceKeyByte() {
if (deviceKey == null) return null;
String s = deviceKey.trim();
// оставляю этот метод как утилиту (иногда удобно), но он работает только для clientKey:
public byte[] getClientKeyByte() {
if (clientKey == null) return null;
String s = clientKey.trim();
if (s.isEmpty()) return null;
try {
@@ -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;
}
}
@@ -0,0 +1,50 @@
package shine.db.entities;
public class TestFreeAvatarUploadEntry {
private String login;
private int usedCount;
private long updatedAtMs;
private String lastTxId;
public TestFreeAvatarUploadEntry() {
}
public TestFreeAvatarUploadEntry(String login, int usedCount, long updatedAtMs, String lastTxId) {
this.login = login;
this.usedCount = usedCount;
this.updatedAtMs = updatedAtMs;
this.lastTxId = lastTxId;
}
public String getLogin() {
return login;
}
public void setLogin(String login) {
this.login = login;
}
public int getUsedCount() {
return usedCount;
}
public void setUsedCount(int usedCount) {
this.usedCount = usedCount;
}
public long getUpdatedAtMs() {
return updatedAtMs;
}
public void setUpdatedAtMs(long updatedAtMs) {
this.updatedAtMs = updatedAtMs;
}
public String getLastTxId() {
return lastTxId;
}
public void setLastTxId(String lastTxId) {
this.lastTxId = lastTxId;
}
}
@@ -8,7 +8,7 @@ package shine.db.entities;
* - param TEXT NOT NULL
* - time_ms INTEGER NOT NULL
* - value TEXT NOT NULL
* - device_key TEXT NULL
* - client_key TEXT NULL
* - signature TEXT NULL
*/
public class UserParamEntry {
@@ -18,17 +18,17 @@ public class UserParamEntry {
private long timeMs;
private String value;
private String deviceKey;
private String clientKey;
private String signature;
public UserParamEntry() {}
public UserParamEntry(String login, String param, long timeMs, String value, String deviceKey, String signature) {
public UserParamEntry(String login, String param, long timeMs, String value, String clientKey, String signature) {
this.login = login;
this.param = param;
this.timeMs = timeMs;
this.value = value;
this.deviceKey = deviceKey;
this.clientKey = clientKey;
this.signature = signature;
}
@@ -44,8 +44,8 @@ public class UserParamEntry {
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDeviceKey() { return deviceKey; }
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; }
public String getClientKey() { return clientKey; }
public void setClientKey(String clientKey) { this.clientKey = clientKey; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
File diff suppressed because it is too large Load Diff
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
File diff suppressed because it is too large Load Diff
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
@@ -10,7 +10,7 @@ import shine.db.entities.ActiveSessionEntry;
*
* Важно (v2):
* - Авторизация всегда 2 шага:
* A) Создание новой сессии через deviceKey:
* A) Создание новой сессии через clientKey:
* AuthChallenge(login) -> ctx.authNonce
* CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession
*
@@ -39,7 +39,7 @@ public class ConnectionContext {
/**
* Одноразовый nonce, выданный на шаге 1 (AuthChallenge),
* используется на шаге CreateAuthSession для проверки подписи deviceKey.
* используется на шаге CreateAuthSession для проверки подписи clientKey.
*/
private String authNonce;
@@ -39,13 +39,19 @@ 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;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
// --- NEW: SearchUsers ---
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
@@ -83,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;
@@ -91,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;
@@ -100,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;
@@ -109,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;
@@ -127,6 +143,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", new Net_AddUser_Handler()),
Map.entry("GetUser", new Net_GetUser_Handler()),
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth ---
Map.entry("AuthChallenge", new Net_AuthChallenge_Handler()),
@@ -155,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()),
@@ -176,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()),
@@ -200,6 +223,8 @@ public final class JsonHandlerRegistry {
Map.entry("AddUser", Net_AddUser_Request.class),
Map.entry("GetUser", Net_GetUser_Request.class),
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth ---
Map.entry("AuthChallenge", Net_AuthChallenge_Request.class),
@@ -228,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),
@@ -249,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),
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
@@ -1,140 +0,0 @@
package server.logic.ws_protocol.JSON.entyties;
/**
* Базовый класс для всех событий (event).
* Общие поля: op и payload.
*.
* Формат JSON (event):
* {
* "op": "...",
* "payload": { ... }
* }
*/
public abstract class Net_Event {
/** Имя операции / события (op). */
private String op;
/**
* Произвольные данные.
* В JSON это поле "payload".
*/
private Object payload;
// --- getters / setters ---
public String getOp() {
return op;
}
public void setOp(String op) {
this.op = op;
}
public Object getPayload() {
return payload;
}
public void setPayload(Object payload) {
this.payload = payload;
}
}
package server.logic.ws_protocol.JSON.entyties;
/**
* Ответ с ошибкой (любой отказ).
*.
* В payload будет:
* {
* "code": "...",
* "message": "..."
* }
*/
public class Net_Exception_Response extends Net_Response {
private String code;
private String message;
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
package server.logic.ws_protocol.JSON.entyties;
/**
* Базовый класс для всех запросов (client → server).
*.
* Наследуется от NetEvent и добавляет requestId.
*.
* Формат JSON (request):
* {
* "op": "...",
* "requestId": "...",
* "payload": { ... }
* }
*/
public abstract class Net_Request extends Net_Event {
/** Идентификатор запроса, чтобы связать запрос и ответ. */
private String requestId;
// --- getters / setters ---
public String getRequestId() {
return requestId;
}
public void setRequestId(String requestId) {
this.requestId = requestId;
}
}
package server.logic.ws_protocol.JSON.entyties;
/**
* Базовый класс для всех ответов (server → client).
*.
* Наследуется от NetRequest и добавляет status.
*.
* Формат JSON (response):
* {
* "op": "...",
* "requestId": "...",
* "status": 200,
* "payload": { ... } // и для успеха, и для ошибки
* }
*/
public abstract class Net_Response extends Net_Request {
/** Статус результата (200 — успех, любое другое значение — ошибка). */
private int status;
// --- getters / setters ---
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
}
public boolean isOk() {
return status == 200;
}
}
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# собрать только *.java файлы и вывести их содержимое в файл
find . -type f -name "*.java" | sort | while read -r f; do
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Все .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"
@@ -136,8 +136,8 @@ final class EspPairingSupport {
| ((digest[1] & 0xFFL) << 16)
| ((digest[2] & 0xFFL) << 8)
| (digest[3] & 0xFFL);
long shortCodeNum = code % 10_000_000L;
String shortCode = String.format(Locale.ROOT, "%07d", shortCodeNum);
long shortCodeNum = code % 10_000_000_000L;
String shortCode = String.format(Locale.ROOT, "%010d", shortCodeNum);
return new PairingFingerprint(shortCode, toBase58(digest));
}
@@ -20,9 +20,9 @@ import java.security.SecureRandom;
* AuthChallenge (v2) шаг 1 создания новой сессии.
*
* Логика авторизации (v2):
* - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя.
* - Создание новой сессии возможно ТОЛЬКО через clientKey пользователя.
* - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге:
* CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...))
* CreateAuthSession(..., signature(clientKey, AUTH_CREATE_SESSION:...))
*
* Что делает:
* 1) Проверяет login.
@@ -30,7 +30,7 @@ import java.security.SecureRandom;
import java.sql.SQLException;
/**
* CreateAuthSession (v2) шаг 2 создания новой сессии (ТОЛЬКО deviceKey).
* CreateAuthSession (v2) шаг 2 создания новой сессии (ТОЛЬКО clientKey).
*
* Логика авторизации (v2):
* - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...)
@@ -38,7 +38,7 @@ import java.sql.SQLException;
* отправляет на сервер sessionKey целиком одной строкой.
* - Сервер сохраняет sessionKey в active_sessions.session_key как есть.
*
* Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8):
* Подпись clientKey (Ed25519) проверяется над строкой (UTF-8):
* AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
*
* На выходе:
@@ -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;
@@ -226,15 +227,15 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
}
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform());
String deviceKeyFromDb = user.getDeviceKey();
if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) {
String clientKeyFromDb = user.getClientKey();
if (clientKeyFromDb == null || clientKeyFromDb.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"NO_DEVICE_KEY",
"Отсутствует deviceKey у пользователя"
"Отсутствует clientKey у пользователя"
);
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no deviceKey");
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no clientKey");
return err;
}
@@ -261,28 +262,28 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err;
}
String deviceKeyFromReq = req.getDeviceKey();
if (deviceKeyFromReq == null || deviceKeyFromReq.isBlank()) {
String clientKeyFromReq = req.getClientKey();
if (clientKeyFromReq == null || clientKeyFromReq.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"EMPTY_DEVICE_KEY",
"Пустой deviceKey"
"Пустой clientKey"
);
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty deviceKey");
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty clientKey");
return err;
}
deviceKeyFromReq = deviceKeyFromReq.trim();
clientKeyFromReq = clientKeyFromReq.trim();
// TODO: для ротации device_key стоит дополнительно сверять актуальное значение через Solana.
if (!deviceKeyFromReq.equals(deviceKeyFromDb)) {
// TODO: для ротации client_key стоит дополнительно сверять актуальное значение через Solana.
if (!clientKeyFromReq.equals(clientKeyFromDb)) {
Net_Response err = NetExceptionResponseFactory.error(
req,
WireCodes.Status.UNVERIFIED,
"DEVICE_KEY_NOT_ACTUAL",
"device_key не соответствует актуальной версии"
"client_key не соответствует актуальной версии"
);
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: device key mismatch");
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: client key mismatch");
return err;
}
@@ -294,7 +295,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
storagePwd,
authNonce,
timeMs,
deviceKeyFromDb,
clientKeyFromDb,
signatureB64
);
} catch (UnsupportedOperationException ex) {
@@ -302,9 +303,9 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
req,
422,
"UNSUPPORTED_KEY_ALGORITHM",
"deviceKey algorithm is not supported"
"clientKey algorithm is not supported"
);
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: unsupported device key algorithm");
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: unsupported client key algorithm");
return err;
} catch (IllegalArgumentException ex) {
Net_Response err = NetExceptionResponseFactory.error(
@@ -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();
@@ -440,11 +441,11 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
String storagePwd,
String authNonce,
long timeMs,
String deviceKey,
String clientKey,
String signatureB64
) throws IllegalArgumentException {
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(deviceKey, "deviceKey");
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(clientKey, "clientKey");
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
String preimageStr = "AUTH_CREATE_SESSION:"

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