Слить обновления UI каналов и кошелька

This commit is contained in:
AidarKC 2026-06-24 15:00:02 +04:00
commit 127c561a41
168 changed files with 2763 additions and 31082 deletions

View File

@ -134,7 +134,7 @@
Что сделать:
- продумать и реализовать смену `root key`, `device key`, `blockchain key`;
- продумать и реализовать смену `root key`, `client key`, `blockchain key`;
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
- продумать, как не потерять доступ и как обновлять доверие к новым ключам.

View File

@ -8,7 +8,7 @@ shine.db.SqliteDbController — один вход в БД: читает db.path,
shine.db.DatabaseInitializer — разовая сборка схемы (таблицы + индексы).
shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getDeviceKeyByte()).
shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getClientKeyByte()).
shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO:
UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit).

View File

@ -38,7 +38,7 @@ message_stats ⭐
solana_users
login — TEXT PK — уникальный логин пользователя
device_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64))
client_key — TEXT NOT NULL — публичный ключ устройства (Base64(32) / HEX(64))
solana_key — TEXT NULL — публичный ключ Solana-аккаунта
active_sessions
@ -61,7 +61,7 @@ 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
client_key — TEXT NULL
signature — TEXT NULL
Ограничение:

View File

@ -35,7 +35,7 @@
"blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY",
"deviceKey": "BASE64_32_PUBLIC_KEY",
"clientKey": "BASE64_32_PUBLIC_KEY",
"bchLimit": 1000000
}
}
@ -99,7 +99,7 @@
"blockchainName": "anya-001",
"solanaKey": "BASE64_32_PUBLIC_KEY",
"blockchainKey": "BASE64_32_PUBLIC_KEY",
"deviceKey": "BASE64_32_PUBLIC_KEY",
"clientKey": "BASE64_32_PUBLIC_KEY",
"serverLastGlobalNumber": 128,
"serverLastGlobalHash": "4f...ab",
"serverBlockchainSizeBytes": 45212,

View File

@ -11,7 +11,7 @@
Логика раздела такая:
- сначала клиент либо начинает создание новой сессии через `deviceKey`;
- сначала клиент либо начинает создание новой сессии через `clientKey`;
- либо начинает вход в уже созданную сессию через `sessionKey`;
- сервер на первом шаге выдаёт challenge/nonce;
- на втором шаге клиент присылает подписанный ответ;
@ -55,7 +55,7 @@
2. Вход в существующую сессию:
`SessionChallenge` -> `SessionLogin`
`deviceKey` используется для создания новой сессии.
`clientKey` используется для создания новой сессии.
`sessionKey` используется для входа в уже созданную сессию.
@ -119,7 +119,7 @@ ed25519/BASE64_PUBLIC_KEY
"storagePwd": "BASE64_OR_APP_SPECIFIC_SECRET",
"timeMs": 1774600000123,
"authNonce": "nonce",
"deviceKey": "BASE64_DEVICE_PUBLIC_KEY",
"clientKey": "BASE64_DEVICE_PUBLIC_KEY",
"signatureB64": "BASE64_SIGNATURE",
"sessionType": 1,
"clientPlatform": "Web",
@ -138,15 +138,15 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
Перед проверкой подписи сервер должен:
1. взять актуальный `solana_users.device_key`;
2. сравнить его с `payload.deviceKey`;
1. взять актуальный `solana_users.client_key`;
2. сравнить его с `payload.clientKey`;
3. только потом проверять подпись.
Если ключ не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`.
Если `clientKey` не совпадает, сервер возвращает ошибку `DEVICE_KEY_NOT_ACTUAL`.
На будущее:
- для ротации `device_key` желательно добавить перепроверку через Solana.
- для ротации `client_key` желательно добавить перепроверку через Solana.
### Успешный ответ
@ -172,15 +172,15 @@ AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
- `501 / NO_LOGIN`у пользователя на сервере не заполнен `login`.
- `400 / EMPTY_STORAGE_PWD` — пустой `storagePwd`.
- `400 / EMPTY_SESSION_KEY` — пустой `sessionKey`.
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `deviceKey` не поддерживается текущим сервером.
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `deviceKey` или `signatureB64`.
- `422 / UNSUPPORTED_KEY_ALGORITHM` — префикс алгоритма в `sessionKey` или `clientKey` не поддерживается текущим сервером.
- `400 / BAD_BASE64` — неверный Base64 в `sessionKey`, `clientKey` или `signatureB64`.
- `400 / EMPTY_SIGNATURE` — пустая подпись.
- `400 / TIME_SKEW` — время клиента отличается от серверного больше допустимого окна.
- `400 / NO_DEVICE_KEY`у пользователя в БД отсутствует `deviceKey`.
- `400 / NO_DEVICE_KEY`у пользователя в БД отсутствует `clientKey`.
- `400 / EMPTY_AUTH_NONCE` — пустой `authNonce`.
- `400 / AUTH_NONCE_MISMATCH``authNonce` не соответствует значению из `AuthChallenge`.
- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `deviceKey`.
- `422 / DEVICE_KEY_NOT_ACTUAL``deviceKey` не совпадает с актуальной версией на сервере.
- `400 / EMPTY_DEVICE_KEY` — в запросе не передан `clientKey`.
- `422 / DEVICE_KEY_NOT_ACTUAL``clientKey` не совпадает с актуальной версией на сервере.
- `422 / BAD_SIGNATURE` — подпись не прошла проверку.
- `460 / SESSION_TYPE_MISMATCH``sessionType` не совпадает с типом сессии, уже опубликованным для этого `sessionKey` в Solana PDA.
- `501 / SESSION_TYPE_PDA_CHECK_FAILED` — сервер не смог проверить `sessionType` по Solana PDA.
@ -314,7 +314,7 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
В этом потоке:
- новое устройство не владеет `deviceKey` и не проходит обычный `CreateAuthSession`;
- новое устройство не владеет `clientKey` и не проходит обычный `CreateAuthSession`;
- пароль проверяется сервером только как фильтр;
- решение об одобрении принимает уже авторизованная доверенная сессия пользователя;
- сервер не расшифровывает `encryptedPayload` и не становится источником приватных ключей.

View File

@ -21,7 +21,7 @@
"param": "display_name",
"time_ms": 1774700000123,
"value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY",
"client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE"
}
}
@ -76,7 +76,7 @@
"param": "display_name",
"time_ms": 1774700000123,
"value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY",
"client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE"
}
}
@ -116,7 +116,7 @@
"param": "display_name",
"time_ms": 1774700000123,
"value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY",
"client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE"
}
]
@ -126,4 +126,4 @@
## Примечание
Имена JSON-полей `time_ms` и `device_key` сейчас соответствуют Java-модели ответа/запроса и должны передаваться именно в таком виде.
Имена JSON-полей `time_ms` и `client_key` сейчас соответствуют Java-модели ответа/запроса и должны передаваться именно в таком виде.

View File

@ -22,7 +22,7 @@ ESP32 становится аппаратным HSM (hardware security module):
### ESP32 (основная работа)
- [ ] Инициализация WiFi (SSID/пароль в NVS)
- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером
- [ ] Авторизация на сервере: `AuthChallenge``CreateAuthSession` через `deviceKey` (уже есть в NVS), сохранить `sessionId` в NVS
- [ ] Авторизация на сервере: `AuthChallenge``CreateAuthSession` через `clientKey` (уже есть в NVS), сохранить `sessionId` в NVS
- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу
- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет
- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх)

View File

@ -56,7 +56,7 @@ seed(32) = SHA-256(material)
|------|---------|---------------------|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
| device / **Solana** | `dev.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(devicePub)`. См. §3. |
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. |
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
Полные роли каждого ключа — в `Dev_Docs/Keys/README.md`.
@ -67,16 +67,16 @@ seed(32) = SHA-256(material)
Отдельного «солана-ключа» нет. На Solana работают два ключа:
- **`dev.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(devicePub)`.
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`.
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
Пополнять SOL нужно именно на этот адрес.
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1
(«create/update оплачиваются с `device_key`», «root_key — не fee payer»).
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
(`create/update`) и через это можно заменить все остальные ключи; `dev.key` — это **пополняемый
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
кошелёк и плательщик комиссий**. Полное описание ролей — `Dev_Docs/Keys/README.md`.
---
@ -118,9 +118,9 @@ homeserver.key:home-b -> ключ B
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147160).
### Solana-ключ / адрес кошелька (UI)
- `shine-UI/js/pages/registration-payment-view.js``deriveUserWalletAddress`: адрес = `base58(devicePub)` (~113).
- `shine-UI/js/pages/topup-view.js``deviceWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.devicePair`.
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `dev.key`, мимо `masterSecret`) удалён.
- `shine-UI/js/pages/registration-payment-view.js``deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113).
- `shine-UI/js/pages/topup-view.js``clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`.
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён.
### Деривация ключей (прошивка ESP32)
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
@ -131,7 +131,7 @@ homeserver.key:home-b -> ключ B
### Формат PDA (куда попадают ключи)
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
`RootKeyBlock` §6, `DeviceKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1.
`RootKeyBlock` §6, `ClientKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1.
### Сервер (тестовый seed)
- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) —

View File

@ -8,9 +8,9 @@
В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `device key`).
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
- `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
@ -30,7 +30,7 @@
`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя.
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `device key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3.
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `Dev_Docs/Keys/DERIVATION.md`, §3.
## `blockchain key`
@ -50,9 +50,9 @@
Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей.
## `device key`
## `client key`
`device key` - общий ключ, который знают доверенные устройства пользователя.
`client key` - общий ключ, который знают доверенные устройства пользователя.
Назначение:
@ -63,11 +63,11 @@
- derivation Arweave-кошелька;
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
Arweave-кошелёк должен выводиться из `device key` по протоколу:
Arweave-кошелёк должен выводиться из `client key` по протоколу:
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md`
Если пользователь теряет только `device key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными.
Если пользователь теряет только `client key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными.
## `session key`
@ -83,7 +83,7 @@ Arweave-кошелёк должен выводиться из `device key` по
- авторизация сессии на сервере;
- привязка устройства к пользователю;
- подтверждение запросов от конкретной сессии;
- доступ к зашифрованному `device key` после успешной авторизации.
- доступ к зашифрованному `client key` после успешной авторизации.
Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает.
@ -108,14 +108,14 @@ Arweave-кошелёк должен выводиться из `device key` по
Обычное устройство обычно имеет:
- собственный `session key`;
- зашифрованный `device key`, который открывается после авторизации;
- зашифрованный `client key`, который открывается после авторизации;
- доступ к DM, звонкам и обычным пользовательским операциям.
Доверенное серверное или аппаратное устройство может иметь:
- `root key`;
- `blockchain key`;
- `device key`;
- `client key`;
- собственный `session key`.
Такая сессия может подписывать операции повышенной важности по запросам пользователя.
@ -139,7 +139,7 @@ Self-message - это сообщение пользователя самому
Входящее сообщение может быть зашифровано:
- `device key`;
- `client key`;
- `session key`;
- отдельным ключом конкретного чата;
- другим ключом, который уже известен клиенту.
@ -158,12 +158,12 @@ Self-message - это сообщение пользователя самому
## Связанные документы
- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`dev.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код).
- `Dev_Docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код).
- `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений.
- `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
- `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств.
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации.
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `device key`.
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `client key`.
## Что нужно уточнить перед реализацией
@ -172,5 +172,5 @@ Self-message - это сообщение пользователя самому
- какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`;
- формат self-message-команд;
- порядок перебора ключей при расшифровке входящих сообщений;
- правила ротации `device key` и восстановления доступа после потери устройства;
- правила ротации `client key` и восстановления доступа после потери устройства;
- какие типы серверных и аппаратных сессий нужны в первой реализации.

View File

@ -11,7 +11,7 @@
- на ESP32 после успешной homeserver-авторизации в `SETTINGS` появляется пункт `Device requests` первым;
- `REFRESH` реально загружает активные заявки;
- на экране видно две плитки, список листается вертикально;
- client-session заявка после `YES` подключается с передачей только `device key`;
- client-session заявка после `YES` подключается с передачей только `client key`;
- wallet-session заявка после `YES` подключается без передачи ключей, через выпуск отдельной wallet-session;
- `NO` отклоняет заявку и она исчезает из списка активных.

View File

@ -2,20 +2,20 @@
- краткое описание:
- в ESP32 заменён домашний блок `баланс + QR` на единый вход в экран кошелька;
- добавлен выбор активного кошелька `DeviceKey / RootKey / Custom`;
- добавлен выбор активного кошелька `ClientKey / RootKey / Custom`;
- для browser extension добавлен первый RPC `get_wallet_public_key` через существующий `wallet-session` и `CallSignalToSession`;
- в popup расширения добавлен запрос текущего кошелька и копирование `publicKeyBase58`.
- что проверять:
- на ESP32 после ввода секрета на главном экране видна кнопка `Кошелёк: ...`;
- экран `WALLET` открывается и показывает текущий тип кошелька;
- экран `WALLET_SELECT` переключает `DeviceKey`, `RootKey` и `Custom`;
- экран `WALLET_SELECT` переключает `ClientKey`, `RootKey` и `Custom`;
- для `Custom` открывается ввод имени и после сохранения derivation работает;
- `Показать баланс кошелька` читает баланс именно активного кошелька;
- `Показать QR-код кошелька` показывает QR и адрес именно активного кошелька;
- browser extension после подключения wallet-session может запросить текущий кошелёк у ESP32;
- extension показывает тип кошелька, полный `publicKeyBase58`, результат проверки через PDA и копирует ключ в буфер;
- для `dev.key` и `root.key` проверка через PDA даёт ожидаемое совпадение.
- для `client.key` и `root.key` проверка через PDA даёт ожидаемое совпадение.
- ожидаемый результат:
- активный кошелёк на ESP32 реально влияет на баланс, QR и ответ `get_wallet_public_key`;

View File

@ -5,7 +5,7 @@
- Что проверять:
1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском.
2. Проверить сценарий выбора `DeviceKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения.
2. Проверить сценарий выбора `ClientKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения.
3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login.
4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`.
5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше.

View File

@ -12,7 +12,7 @@
6. Проверить оба варианта:
- `APPROVE` возвращает сайту подписанную транзакцию;
- `REJECT` возвращает отказ.
7. Проверить сценарии для `DeviceKey`, `RootKey`, `Custom`.
7. Проверить сценарии для `ClientKey`, `RootKey`, `Custom`.
- Ожидаемый результат:
- сайт может подключить кошелёк через provider расширения;

View File

@ -0,0 +1,26 @@
## Кратко
Исправлена ESP32-ветка обновления `user_pda` для добавления `homeserver`-сессии после миграции формата PDA на `RecoveryKeyBlock`.
## Что сделано
- В `shine_homeserver_main.ino` синхронизирован `create/update` payload с новым форматом `shine_users`.
- В сериализацию и парсинг PDA добавлен `RecoveryKeyBlock`.
- Для ветки `Add Homeserver` добавлены промежуточные checkpoint-записи в NVS, чтобы после crash или reset было видно, на каком шаге оборвалась операция.
- В `ESP32/AGENTS.md` добавлена памятка по чтению `last_error`.
## Что проверять
- Зарегистрировать или использовать уже существующий аккаунт на ESP32.
- Дойти до состояния `homeserver not in PDA`.
- Нажать `Add Homeserver`.
- Если операция не успешна, считать `last_error` по USB serial и убедиться, что видна свежая запись именно по шагам `Homeserver PDA update ...`, а не старый diag.
## Ожидаемый результат
- `Add Homeserver` добавляет `homeserver1` в `sessions` блока `SessionsBlock`.
- Если операция падает, в NVS сохраняется свежая диагностическая запись с текущим этапом, а не устаревший лог регистрации.
## Статус
`pending`

View File

@ -0,0 +1,28 @@
# Общий список каналов без stories
- Краткое описание:
вкладка `Каналы` переведена на единый список без разделения на "мои" и "подписки".
Название канала в списке теперь показывается как `login_владельцаазваниеанала`.
Служебный канал `stories` скрыт из списка каналов, поиска, подписки и связанных UI-сценариев.
- Что проверять:
1. Открыть вкладку `Каналы`.
2. Убедиться, что сразу показывается один общий список.
3. Проверить, что свои и чужие каналы отображаются вместе.
4. Проверить формат названий: `ownerLogin/channelName`.
5. Открыть свой канал и убедиться, что внутри сохраняется UI владельца.
6. Открыть чужой канал и убедиться, что внутри сохраняется UI подписчика.
7. Проверить, что `stories` не отображается:
- в общем списке;
- в поиске каналов;
- в подписке на канал;
- в списках выбора канала для репоста.
- Ожидаемый результат:
- вкладка `Каналы` больше не делится на два режима;
- все видимые каналы идут единым списком;
- `stories` нигде не виден и не предлагается пользователю;
- переход в канал сохраняет корректный UI в зависимости от владельца.
- Статус:
`pending`

View File

@ -12,8 +12,9 @@
- логин пользователя;
- неизменяемые параметры создания записи;
- публичный recovery-ключ пользователя;
- корневой публичный ключ пользователя;
- ключ устройства;
- клиентский публичный ключ пользователя;
- данные одного или нескольких пользовательских блокчейнов SHiNE;
- серверные данные пользователя, если пользователь выступает сервером;
- серверы доступа пользователя;
@ -34,9 +35,9 @@
## 2.1. Кто оплачивает create/update PDA
- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `device_key`.
- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `client_key`.
- `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer.
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `device_key`.
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `client_key`.
## 3. Общие правила кодирования
@ -85,8 +86,9 @@ UserPdaRecordV1
| block_type | Блок | Назначение |
|------------|------|------------|
| `0` | `RecoveryKeyBlock` | Ключ восстановления пользователя. |
| `1` | `RootKeyBlock` | Корневой ключ пользователя. |
| `2` | `DeviceKeyBlock` | Ключ устройства пользователя. |
| `2` | `ClientKeyBlock` | Клиентский ключ пользователя. |
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
@ -97,13 +99,31 @@ UserPdaRecordV1
Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`;
- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`;
- каждый обязательный блок должен встречаться ровно один раз;
- порядок блоков в записи фиксируется для простоты проверки:
`RootKey`, `DeviceKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
`RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`.
## 6. RootKeyBlock
## 6. RecoveryKeyBlock
Recovery-ключ нужен для будущих сценариев восстановления и ротации остальных ключей. В текущей версии он только публикуется в записи и не меняется через обычный `update_user_pda`.
```text
RecoveryKeyBlock
- block_type: u8 = 0
- block_version: u8 = 0
- recovery_key: [u8; 32]
```
Правила:
- при создании задается публичный recovery-ключ пользователя;
- при обновлении `recovery_key` должен совпадать с предыдущей записью;
- приватный `recovery.key` в PDA не хранится;
- отдельная ротация recovery-ключа будет отдельным форматом/сценарием в будущем.
## 7. RootKeyBlock
Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`.
@ -120,24 +140,24 @@ RootKeyBlock
- при обновлении `root_key` должен совпадать с предыдущей записью;
- ротация root-key будет отдельным форматом/сценарием в будущем.
## 7. DeviceKeyBlock
## 8. ClientKeyBlock
Смена `device_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один ключ устройства.
Смена `client_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один клиентский ключ пользователя.
```text
DeviceKeyBlock
ClientKeyBlock
- block_type: u8 = 2
- block_version: u8 = 0
- device_key: [u8; 32]
- client_key: [u8; 32]
```
Правила:
- при создании задается текущий публичный ключ устройства;
- при обновлении ключ устройства может быть обновлен только если это отдельно разрешено бизнес-логикой инструкции;
- история устройств и несколько устройств в этом формате не хранятся.
- при создании задается текущий клиентский публичный ключ пользователя;
- при обновлении `client_key` должен совпадать с предыдущей записью;
- история устройств и несколько клиентских ключей в этом формате не хранятся.
## 8. BlockchainRegistryBlock
## 9. BlockchainRegistryBlock
Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список.
@ -155,7 +175,7 @@ BlockchainRegistryBlock
- в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`;
- каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн.
## 9. BlockchainRecord
## 10. BlockchainRecord
```text
BlockchainRecord
@ -191,7 +211,7 @@ BlockchainRecord
Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя.
## 10. Правила обновления BlockchainRecord
## 11. Правила обновления BlockchainRecord
При обновлении записи:
@ -229,7 +249,7 @@ last_block_signature = Ed25519(blockchain_public_key, message)
Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера.
## 11. ServerProfileBlock
## 12. ServerProfileBlock
Блок присутствует, если пользователь выступает сервером.
@ -255,7 +275,7 @@ ServerProfileBlock
- `server_address` - строковый адрес сервера в соответствии с `address_format_type`;
- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
## 12. AccessServersBlock
## 13. AccessServersBlock
Блок хранит серверы доступа/relay для пользователя.
@ -274,7 +294,7 @@ AccessServersBlock
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
## 13. SessionsBlock
## 14. SessionsBlock
Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком.
@ -309,6 +329,7 @@ SessionRecord
| Значение | Смысл |
|----------|-------|
| `1` | Обычная пользовательская сессия. |
| `50` | Кошелёк пользователя. |
| `100` | Homeserver пользователя. |
Правила:
@ -320,7 +341,7 @@ SessionRecord
- внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`;
- на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически.
## 14. TrustedStateBlock
## 15. TrustedStateBlock
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
@ -333,7 +354,7 @@ TrustedStateBlock
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 15. Подпись user_pda
## 16. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи:
@ -354,7 +375,7 @@ Solana-программа проверяет подпись через встр
Смену формата подписи сейчас не трогаем.
## 16. Регистрация пользователя
## 17. Регистрация пользователя
При регистрации:
@ -372,12 +393,12 @@ Solana-программа проверяет подпись через встр
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
- вся unsigned-часть записи подписана `root_key`.
## 17. Обновление пользователя
## 18. Обновление пользователя
При обновлении:
- PDA должна существовать;
- `login`, `created_at_ms`, `root_key` не меняются;
- `login`, `created_at_ms`, `recovery_key`, `root_key`, `client_key` не меняются;
- `record_number = previous_record_number + 1`;
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется;
@ -387,7 +408,7 @@ Solana-программа проверяет подпись через встр
- при увеличении оплаченного лимита пользователь доплачивает комиссию;
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
## 18. Отличия от старого линейного формата
## 19. Отличия от старого линейного формата
Старый формат после `login` хранил поля линейно:
@ -395,8 +416,8 @@ Solana-программа проверяет подпись через встр
- `root_key`;
- `blockchain_key_status`;
- `blockchain_key`;
- `device_key_status`;
- `device_key`;
- `client_key_status`;
- `client_key`;
- `chain_number`;
- `balance`;
- серверные поля;
@ -407,17 +428,54 @@ Solana-программа проверяет подпись через встр
Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки:
- recovery-ключ становится отдельным обязательным блоком;
- ключи становятся отдельными блоками;
- данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`;
- серверные данные и access-серверы отделяются от данных блокчейна;
- расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи.
## 18. Что пока не входит в формат
## 20. Деривация ключей из master secret
Сама Solana-программа не вычисляет ключи из секрета и не хранит приватные ключи. Но текущая согласованная клиентская схема деривации для публичной версии формата фиксируется здесь как reference для UI/ESP32/внешних клиентов.
Базовая формула:
```text
seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8)
```
Где:
- `master_secret32` — 32-байтовый master secret пользователя;
- `suffix_utf8` — строка назначения ключа.
Согласованные suffix:
```text
"recovery.key"
"root.key"
"blockchain.key"
"client.key"
```
Соответствие:
```text
recovery.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "recovery.key")
root.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "root.key")
blockchain.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "blockchain.key")
client.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "client.key")
```
Далее каждая строка `seed` интерпретируется off-chain как `seed32` для отдельной пары Ed25519.
## 21. Что пока не входит в формат
Пока не проектируем:
- ротацию `recovery_key`;
- ротацию `root_key`;
- сложную ротацию `device_key`;
- сложную ротацию `client_key`;
- ротацию `blockchain_public_key`;
- проверку содержимого Arweave transaction;
- хранение полной истории пользовательского блокчейна внутри Solana;

View File

@ -47,7 +47,7 @@ DAO в текущем виде не является отдельной Anchor-
| Программа | Program ID |
| --- | --- |
| `shine_login_guard` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` |
| `shine_users` | `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` |
| `shine_users` | `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` |
| `shine_payments` | `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` |
Если эти адреса меняются, нужно синхронно обновить:

View File

@ -19,7 +19,7 @@
5. `DAO_TREASURY_WALLET` / `dao_wallet` — казна DAO.
6. `manager_wallet` — кошелек менеджера, которому DAO выдает лимиты на создание тикетов.
7. `user root_key` — корневой ключ пользователя для подписи пользовательской записи.
8. `user device_key` — ключ устройства пользователя.
8. `user client_key` — ключ устройства пользователя.
9. `server_key` — ключ сервера пользователя, если пользователь является сервером.
Текущие адреса из `programs/common/src/deploy_config.rs`:
@ -27,7 +27,7 @@
| Роль | Адрес |
| --- | --- |
| `SHINE_LOGIN_GUARD_PROGRAM_ID` | `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` |
| `SHINE_USERS_PROGRAM_ID` | `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` |
| `SHINE_USERS_PROGRAM_ID` | `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ` |
| `SHINE_PAYMENTS_PROGRAM_ID` | `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` |
| `DAO_AUTHORITY` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` |
| `DAO_TREASURY_WALLET` | `FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P` |

View File

@ -62,7 +62,7 @@
`UserMutableFields`:
- `device_key: Pubkey`
- `client_key: Pubkey`
- `blockchain_public_key: Pubkey`
- `blockchain_name: String`
- `used_bytes: u64`

View File

@ -32,7 +32,7 @@
<rect class="box actor" x="52" y="150" width="210" height="78"/>
<text class="txt" x="72" y="181">Пользователь</text>
<text class="small" x="72" y="206">signer, root_key, device_key</text>
<text class="small" x="72" y="206">signer, root_key, client_key</text>
<rect class="box actor" x="52" y="310" width="210" height="78"/>
<text class="txt" x="72" y="341">Покупатель тикета</text>

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@ -43,8 +43,8 @@ bump, подмена аккаунта оракула) не найдено. Вс
|---|---|---|---|---|---|---|
| `init_users_economy_config` | ✓ | `owner == system` + `data_is_empty` (анти-reinit) | деривация + сверка | ✓ | — | значения из `settings`, не из ввода |
| `update_users_economy_config` | ✓ + `signer == DAO_AUTHORITY` | `owner == program_id` | деривация + сверка | — | — | `lamports_per_limit_step > 0` |
| `create_user_pda` | ✓ + `signer == device_key` | user_pda `owner == system` + empty; econ_config `owner == program_id` | user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx 2, last_block idx 1) | `inflow_vault` сверен с PDA `shine_payments`; login_guard сверен дважды |
| `update_user_pda` | ✓ + `signer == device_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью |
| `create_user_pda` | ✓ + `signer == client_key` | user_pda `owner == system` + empty; econ_config `owner == program_id` | user_pda, econ_config, inflow_vault, login_guard — все сверены | ✓ | ed25519 (record sig idx 2, last_block idx 1) | `inflow_vault` сверен с PDA `shine_payments`; login_guard сверен дважды |
| `update_user_pda` | ✓ + `signer == client_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью |
### shine_payments
@ -111,7 +111,7 @@ commit-reveal; для текущей модели — приемлемый ри
- **Подмена PDA** невозможна нигде: всюду пара «деривация `find_program_address` + сверка полного адреса». Пользовательский bump не принимается, `create_program_address` с внешним bump не используется — bump-seed атаки исключены.
- **Проверка владельца** при каждом чтении PDA: `read_state` и `validate_singleton_state_pda` (`shine_payments`) требуют `owner == id()`; `validate_users_economy_config_pda` и проверка `user_pda.owner == program_id` (`shine_users`) — перед десериализацией данных.
- **Создаваемые PDA**: проверка `is_uninitialized` / `owner == system && data_is_empty` исключает повторную инициализацию и перезапись чужого аккаунта.
- **signer / authority**: все handler начинают с обязательного `is_signer`; привилегированные операции дополнительно сверяют ключ с авторитетом (`config.dao_wallet`, `DAO_AUTHORITY`, `allowance.manager_wallet`, `ticket.recipient_wallet`, `device_key`).
- **signer / authority**: все handler начинают с обязательного `is_signer`; привилегированные операции дополнительно сверяют ключ с авторитетом (`config.dao_wallet`, `DAO_AUTHORITY`, `allowance.manager_wallet`, `ticket.recipient_wallet`, `client_key`).
- **system-program** сверяется с `system_program::ID` там, где идёт создание аккаунта/перевод; **sysvar инструкций** сверяется с `sysvar::instructions::id()` перед ed25519-интроспекцией.
- **Аккаунт оракула**: пин адреса `PYTH_SOL_USD_ACCOUNT` + `owner == pyth_receiver` + `feed_id` + возраст (120 с) + доверительный интервал (10%).
- **Ed25519 в `shine_users`**: относительные индексы 1/2, `num_signatures == 1`, все три `ix_index == u16::MAX` (offset-данные внутри самой ed25519-инструкции), сверка `program_id == ed25519_program` и pubkey/signature/message по хэшу — указать на чужую инструкцию нельзя.

View File

@ -12,7 +12,7 @@
## Актуальные адреса (devnet)
- `shine_users` (регистрация пользователей):
`FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
`3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- `shine_login_guard`:
`3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- `shine_payments`:
@ -22,7 +22,7 @@
- Сеть: `https://api.devnet.solana.com`
- `shine_users`:
- `Program ID`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `Program ID`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM`
- `shine_login_guard`:
- `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
@ -64,11 +64,38 @@ anchor deploy -p shine_users
- Переход на страницу:
- `shine-UI/js/pages/developer-settings-view.js`
### Browser plugin wallet
- Резолвер PDA и проверка адресов:
- `SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js`
- Проверка текущего wallet по PDA:
- `SHiNE-browser-plugin-wallet/background.js`
- Отображение состояния в popup:
- `SHiNE-browser-plugin-wallet/popup.js`
### Сервер
- Серверные константы Solana:
- `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java`
### Solana / Anchor
- `shine-solana/shine/Anchor.toml`
- `shine-solana/shine/programs/shine_users/src/lib.rs` (`declare_id!`)
- `shine-solana/shine/programs/shine_login_guard/src/lib.rs` (`declare_id!`)
- `shine-solana/shine/programs/shine_payments/src/lib.rs` (`declare_id!`)
### Где ещё нужно синхронизировать адреса после нового deploy
- UI-константы и все потребители в `shine-UI/js/*`
- browser-plugin-wallet
- серверный `SolanaProgramsConfig.java`
- Anchor-конфиг и `declare_id!` в Solana-модуле
- документы:
- `Dev_Docs/Solana/user_pda/README.md`
- `shine-solana/shine/doc/programs/shine_users.md`
- `shine-solana/shine/doc/devnet_keys_and_deploy.md`
## Как запустить инициализацию economy PDA
1. Открыть UI.
@ -80,19 +107,19 @@ anchor deploy -p shine_users
Страница сама вычисляет PDA `users_economy_config` по seed:
- seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- program: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`.
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
## Важно
- `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение).
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `deviceKey`, а содержимое записи подписывает `rootKey`.
- Серверные приватные ключи для Solana не используются как отдельный backend-wallet: в UI/server UI транзакцию оплачивает именно `clientKey`, а содержимое записи подписывает `rootKey`.
- `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации.

View File

@ -4,7 +4,7 @@
## 1. Текущие режимы
### 1. Создание новой сессии через `deviceKey`
### 1. Создание новой сессии через `clientKey`
Поток:
@ -12,8 +12,8 @@
Смысл:
- новое устройство уже владеет приватным `deviceKey`;
- сервер проверяет подпись `deviceKey`;
- новое устройство уже владеет приватным `clientKey`;
- сервер проверяет подпись `clientKey`;
- создаётся обычная активная сессия пользователя;
- этот поток остаётся без изменений.
@ -67,7 +67,7 @@
## 4. Чего сервер в этой версии не делает
- не передаёт приватный `deviceKey`;
- не передаёт приватный `clientKey`;
- не расшифровывает `encryptedPayload`;
- не проверяет криптографию содержимого payload;
- не делает клиентский UI;

View File

@ -3,11 +3,11 @@
Сокращение: **SAWD-v1**.
## Назначение
Из 32-байтного `deviceKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address.
Из 32-байтного `clientKey32` пользователя получить один и тот же нативный Arweave RSA-4096 JWK wallet и один и тот же Arweave address.
## Вход
- `deviceKey32`: ровно 32 байта.
- Если исходный `device.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8.
- `clientKey32`: ровно 32 байта.
- Если исходный `client.key` хранится как Ed25519 PKCS8 base64, нужно извлечь последние 32 байта из PKCS8.
- Если используется Solana keypair JSON на 64 байта, используются только `bytes[0..31]`.
## Выход
@ -46,8 +46,8 @@
- `SMALL_PRIME_LIMIT = 10000`
## Алгоритм
1. Проверить `deviceKey32.length == 32`.
2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = deviceKey32)`.
1. Проверить `clientKey32.length == 32`.
2. `masterSeed32 = HMAC-SHA256(key = UTF8(MASTER_LABEL), message = clientKey32)`.
3. Реализовать `deriveBytes(label, length)`:
- `output = empty`
- `counter = 0`

View File

@ -16,7 +16,7 @@
На устройстве в UI пользователь выбирает текущий активный кошелёк:
- `dev.key`
- `client.key`
- `root.key`
- `custom`
@ -115,7 +115,7 @@ ESP32 возвращает:
- `requestId` — должен совпадать с `requestId` исходного запроса.
- `ok` — признак успешного результата.
- `wallet.type` — тип активного кошелька:
- `dev.key`
- `client.key`
- `root.key`
- `custom`
- `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`.
@ -156,11 +156,11 @@ ESP32 возвращает:
Расширение уже знает публичные ключи пользователя из Solana PDA. Поэтому оно может дополнительно проверить ответ ESP32:
- если `wallet.type = dev.key`, то `publicKeyBase58` должен совпасть с `deviceKey`, прочитанным из PDA;
- если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA;
- если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA;
- если `wallet.type = custom`, такой проверки по PDA в первой версии нет.
При несовпадении для `dev.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA.
При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA.
## 8. Ожидаемое поведение UI расширения

View File

@ -9,3 +9,20 @@
## Синхронизация со спецификацией
- При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `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 и может пережить перезагрузку устройства.

View File

@ -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)

View File

@ -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+");

View File

@ -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,7 +239,7 @@ 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 *kShineUsersProgramId = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
static const char *kUsersSeedPrefix = "user_login=";
@ -246,7 +248,7 @@ 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;
}
@ -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 = "Удалить секрет, кошелёк и статус регистрации?";

View File

@ -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`
- 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.
Показывает:
- заголовок;
@ -291,7 +291,7 @@
Используется `Preferences` (NVS памяти ESP32):
- `solana_rpc`
- `shine_server`
- `shine_server_login`
## Хранение аккаунта

View File

@ -65,12 +65,13 @@
- `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-сеанс:
@ -86,7 +87,7 @@
Кнопка регистрации доступна только если одновременно выполнены условия:
1. настроен и подтверждён `Wi-Fi`;
2. заполнены и подтверждены серверные адреса;
2. задан и подтверждён `SHiNE server login`;
3. задан логин;
4. сгенерирован или введён секрет;
5. баланс кошелька не меньше `0.20 SOL`;
@ -178,7 +179,7 @@
- вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка;
- текст кнопки: `Wallet: <selected wallet name>`;
- доступные имена:
- `DeviceKey`
- `ClientKey`
- `RootKey`
- либо сохранённое имя `custom`-кошелька;
- после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`;
@ -429,7 +430,7 @@
- верхняя кнопка открывает экран выбора кошелька `WALLET_SELECT`;
- `Показать баланс кошелька` читает реальный баланс именно активного выбранного кошелька из `Solana RPC`;
- `Показать QR-код кошелька` открывает экран `WALLET_QR`;
- этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `device key`.
- этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `client key`.
## Экран WALLET_SELECT
@ -437,14 +438,14 @@
- строку `Current: <selected wallet name>`;
- три кнопки выбора:
- `DeviceKey`
- `ClientKey`
- `RootKey`
- `Custom` или `Custom: <имя>`;
- у текущего выбора видна галочка.
Поведение:
- `DeviceKey` активирует кошелёк, выведенный из suffix `dev.key`;
- `ClientKey` активирует кошелёк, выведенный из suffix `client.key`;
- `RootKey` активирует кошелёк, выведенный из suffix `root.key`;
- `Custom` использует derivation:
`sha256(base64(secret32) + "|wallet." + customName)`;
@ -533,7 +534,7 @@
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`;
- пояснение:
- для client session: `Only device key will be transferred. No additional keys will be sent.`
- для client session: `Only client key will be transferred. No additional keys will be sent.`
- для wallet session: `No keys will be transferred.`
Кнопки:
@ -544,7 +545,7 @@
Поведение:
- `YES` подтверждает заявку:
- для client session устройство передаёт только `device key`;
- для client session устройство передаёт только `client key`;
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
- `NO` отклоняет заявку;
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
@ -628,7 +629,7 @@
2. открыть `Подключение -> Wi-Fi`;
3. ввести `SSID` и пароль, нажать `Проверить`;
4. открыть `Подключение -> Серверы`;
5. проверить или задать серверные адреса;
5. проверить или задать `SHiNE server login` (по умолчанию `shineupme`);
6. открыть `Аккаунт`;
7. ввести логин;
8. задать имя homeserver;
@ -637,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`.
## Сценарий входящего запроса

View File

@ -7,7 +7,7 @@ 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`, а side panel использовать как UI.

View File

@ -30,6 +30,9 @@ const state = {
devicesResolvedAtMs: 0,
},
currentWallet: null,
pendingApprovals: [],
siteApprovalChain: Promise.resolve(),
sessionAttachInProgress: false,
statusText: '',
statusKind: 'info',
};
@ -69,6 +72,55 @@ function setStatus(message = '', kind = 'info') {
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);
@ -103,7 +155,10 @@ async function loadStateFromStorage() {
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 = {
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
@ -317,8 +372,17 @@ async function attachApprovedSession(payload) {
throw new Error('Получен неполный session-only payload');
}
await clearSessionMaterial();
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({
@ -329,6 +393,9 @@ async function attachApprovedSession(payload) {
});
state.connectionOnline = false;
state.currentWallet = null;
} finally {
state.sessionAttachInProgress = false;
}
}
async function pollPairingStatus() {
@ -506,7 +573,7 @@ async function resolveSelectedHomeserverSession() {
return selectedDevice;
}
async function callWalletRpc(requestData, timeoutMs = 8000) {
async function callWalletRpc(requestData, timeoutMs = 8000, abortSignal = null) {
const selectedDevice = await resolveSelectedHomeserverSession();
const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) {
@ -523,22 +590,46 @@ async function callWalletRpc(requestData, timeoutMs = 8000) {
try {
const response = await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
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);
const off = ensureApi().onEvent('IncomingCallSignal', (evt) => {
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;
clearTimeout(timeoutId);
off();
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,
@ -546,8 +637,7 @@ async function callWalletRpc(requestData, timeoutMs = 8000) {
type: WALLET_RPC_REQUEST_TYPE,
data: JSON.stringify(payload),
}).catch((error) => {
clearTimeout(timeoutId);
off();
cleanup();
reject(error);
});
});
@ -563,11 +653,13 @@ function verifyWalletAgainstPda(wallet) {
const type = String(wallet?.type || '').trim();
const pub = String(wallet?.publicKeyBase58 || '').trim();
const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const deviceKey = String(state.walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
if (type === 'dev.key') {
const clientKey = String(
state.walletProfile?.publicKeys?.clientKeyBase58 || '',
).trim();
if (type === 'client.key') {
return {
verified: !!deviceKey && deviceKey === pub,
verificationText: deviceKey === pub ? 'Совпадает с deviceKey из PDA.' : 'Не совпадает с deviceKey из PDA.',
verified: !!clientKey && clientKey === pub,
verificationText: clientKey === pub ? 'Совпадает с clientKey из PDA.' : 'Не совпадает с clientKey из PDA.',
};
}
if (type === 'root.key') {
@ -604,6 +696,60 @@ async function requestCurrentWallet() {
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) {
@ -634,7 +780,7 @@ async function siteDisconnect({ origin } = {}) {
return { ok: true };
}
async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment } = {}) {
async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment, transactionSummary } = {}, sender = null) {
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) {
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
@ -647,8 +793,18 @@ async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64,
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',
@ -656,7 +812,7 @@ async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64,
publicKeyBase58: cleanPub,
transactionBase64: cleanTx,
comment: signComment,
}, 120000);
}, 120000, activePending.abortController?.signal || null);
if (!response?.ok) {
const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase();
if (errorCode === 'REJECTED_BY_USER') {
@ -664,12 +820,17 @@ async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64,
}
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() {
@ -686,6 +847,7 @@ function snapshot() {
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,
@ -747,6 +909,11 @@ 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() });
@ -758,7 +925,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return;
}
if (type === 'wallet:siteSignTransaction') {
const result = await siteSignTransaction(message?.payload || {});
const result = await siteSignTransaction(message?.payload || {}, _sender);
sendResponse({ ok: true, result, state: snapshot() });
return;
}

View File

@ -1,5 +1,6 @@
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;
@ -20,14 +21,21 @@ function respondToPage(id, ok, result, error, code) {
result: result || null,
error: error ? String(error) : '',
code: code ? String(code) : '',
}, window.location.origin);
}, PAGE_MESSAGE_TARGET_ORIGIN);
}
function sendRuntimeMessage(type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message || 'Runtime message failed'));
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) {
@ -71,6 +79,7 @@ window.addEventListener('message', (event) => {
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;

View File

@ -2,7 +2,7 @@ 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 SHINE_USERS_PROGRAM_ID = '3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ';
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,
},

View File

@ -198,6 +198,12 @@ select {
gap: 8px;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-row {
padding: 8px 10px;
border: 1px solid #243446;
@ -205,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;

View File

@ -18,7 +18,6 @@
</div>
<p id="server-login-info" class="muted small">Сервер SHiNE: —</p>
<p id="server-address" class="muted small">Адрес: —</p>
<div id="connect-card" class="card">
<div class="card-title">Подключение</div>
@ -50,9 +49,6 @@
<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="summary-row"><span>Session</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="disconnect-btn" class="btn danger" type="button">Отключить</button>
@ -72,6 +68,16 @@
</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>

View File

@ -2,7 +2,6 @@ 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'),
@ -17,9 +16,6 @@ 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'),
@ -27,6 +23,10 @@ const els = {
deviceSelect: document.querySelector('#device-select'),
homeserverList: document.querySelector('#homeserver-list'),
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'),
@ -52,6 +52,7 @@ let state = {
selectedDeviceName: '',
},
currentWallet: null,
pendingApproval: null,
status: {
text: '',
kind: 'info',
@ -107,13 +108,50 @@ 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();
els.serverLoginInfo.textContent = resolvedServerLogin ? `Сервер SHiNE: ${resolvedServerLogin}` : 'Сервер SHiNE: —';
els.serverAddress.textContent = resolvedServerAddress ? `Адрес: ${resolvedServerAddress}` : 'Адрес: —';
els.serverLoginInfo.textContent = resolvedServerLogin && resolvedServerAddress
? `Сервер SHiNE: ${resolvedServerLogin} (${resolvedServerAddress})`
: 'Сервер SHiNE: —';
if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue;
}
@ -125,16 +163,15 @@ function applyState(nextState) {
const walletProfile = state?.walletProfile;
const signing = state?.signing || {};
const currentWallet = state?.currentWallet || null;
const pendingApproval = state?.pendingApproval || null;
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 || '—';
els.sessionId.textContent = session.sessionId || '—';
els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—');
els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || '');
}
const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : [];
@ -149,6 +186,19 @@ function applyState(nextState) {
renderHomeserverList(homeservers);
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 || '—';
@ -313,6 +363,14 @@ async function copyWalletKey() {
}
}
async function cancelPendingApproval() {
try {
await sendMessage('wallet:cancelPendingSiteApproval');
} catch (error) {
setStatus(error.message || 'Не удалось отменить ожидание подписи.', 'error');
}
}
function startUiRefreshLoop() {
stopUiRefreshLoop();
refreshTimer = window.setInterval(() => {
@ -347,6 +405,7 @@ function bindUi() {
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() {

View File

@ -2,6 +2,7 @@ import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/so
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'];
@ -39,6 +40,45 @@ function createProviderError(message, 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) => {
@ -59,7 +99,7 @@ function createRequest(method, params = {}) {
id,
method,
params,
}, window.location.origin);
}, PAGE_MESSAGE_TARGET_ORIGIN);
});
}
@ -122,12 +162,6 @@ class ShineProviderCore {
async connect(options = {}) {
const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent;
if (!onlyIfTrusted) {
const confirmed = window.confirm(`Connect SHiNE Wallet to ${window.location.origin}?`);
if (!confirmed) {
throw createProviderError('User rejected wallet connection', 'USER_REJECTED');
}
}
const result = await createRequest('connect', { onlyIfTrusted });
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
this.publicKey = nextKey;
@ -157,10 +191,12 @@ class ShineProviderCore {
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);
}
@ -169,10 +205,20 @@ class ShineProviderCore {
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());
}
@ -262,6 +308,15 @@ class ShineSolanaProvider {
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;
@ -275,6 +330,12 @@ class ShineSolanaProvider {
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');
}
}

View File

@ -42,7 +42,7 @@ shine-UI/server-ui.html
Для обновления — только root + device (blockchain-ключ не нужен).
Актуальные адреса программ Solana (devnet):
- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_users`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`

File diff suppressed because it is too large Load Diff

View File

@ -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)"

View File

@ -12,10 +12,9 @@ public final class SolanaProgramsConfig {
public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com";
// Программа регистрации пользователей (shine_users), задеплоена в devnet.
public static final String SHINE_USERS_PROGRAM_ID = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
public static final String SHINE_USERS_PROGRAM_ID = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
// Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково.
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
public static final String SHINE_PAYMENTS_PROGRAM_ID = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
}

View File

@ -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)"

View File

@ -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

View File

@ -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)"

View File

@ -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)

View File

@ -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;
}

View File

@ -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

View File

@ -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;

View File

@ -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 {

View File

@ -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; }

View File

@ -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

View File

@ -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)"

View File

@ -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)"

View File

@ -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;

View File

@ -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)"

View File

@ -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;
}
}

View File

@ -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)"

View File

@ -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.

View File

@ -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}
*
* На выходе:
@ -226,15 +226,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 +261,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 +294,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
storagePwd,
authNonce,
timeMs,
deviceKeyFromDb,
clientKeyFromDb,
signatureB64
);
} catch (UnsupportedOperationException ex) {
@ -302,9 +302,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(
@ -440,11 +440,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:"

View File

@ -48,9 +48,9 @@ public final class SolanaUserPdaImportService {
boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain(
parsed.login,
parsed.blockchainName,
parsed.deviceKeyB64, // в текущей модели solanaKey = deviceKey
parsed.clientKeyB64, // в текущей модели solanaKey = clientKey
parsed.blockchainKeyB64,
parsed.deviceKeyB64,
parsed.clientKeyB64,
sizeLimit,
now
);
@ -158,7 +158,7 @@ public final class SolanaUserPdaImportService {
int blocksCount = u8(raw, c++);
String blockchainName = null;
byte[] blockchainKey32 = null;
byte[] deviceKey32 = null;
byte[] clientKey32 = null;
long paidLimitBytes = 0L;
List<ParsedSessionRecord> sessions = new ArrayList<>();
@ -167,10 +167,12 @@ public final class SolanaUserPdaImportService {
int blockVer = u8(raw, c++);
if (blockVer != 0) return null;
if (blockType == 1) {
if (blockType == 0) {
c += 32; // recovery_key
} else if (blockType == 1) {
c += 32;
} else if (blockType == 2) {
deviceKey32 = slice(raw, c, 32);
clientKey32 = slice(raw, c, 32);
c += 32;
} else if (blockType == 3) {
int count = u8(raw, c++);
@ -245,12 +247,12 @@ public final class SolanaUserPdaImportService {
if (c > recordLen) return null;
}
if (blockchainName == null || blockchainKey32 == null || deviceKey32 == null) return null;
if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null;
return new ParsedSolanaUser(
login,
blockchainName,
Base64.getEncoder().encodeToString(blockchainKey32),
Base64.getEncoder().encodeToString(deviceKey32),
Base64.getEncoder().encodeToString(clientKey32),
paidLimitBytes,
sessions
);
@ -318,7 +320,7 @@ public final class SolanaUserPdaImportService {
String login,
String blockchainName,
String blockchainKeyB64,
String deviceKeyB64,
String clientKeyB64,
long paidLimitBytes,
List<ParsedSessionRecord> sessions
) {}

View File

@ -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)"

View File

@ -35,7 +35,7 @@
1. Добавление пользователя (AddUser)
Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и deviceKey.
Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и clientKey.
📤 Запрос клиента
{
@ -46,7 +46,7 @@
"loginId": 100212,
"bchId": 4222,
"solanaKey": "BASE64_LOGIN_KEY",
"deviceKey": "BASE64_DEVICE_KEY",
"clientKey": "BASE64_DEVICE_KEY",
"bchLimit": 1000000
}
}
@ -62,7 +62,7 @@ login TEXT NOT NULL,
loginId INTEGER PRIMARY KEY,
bchId INTEGER NOT NULL,
solanaKey TEXT,
deviceKey TEXT,
clientKey TEXT,
bchLimit INTEGER
);
@ -118,7 +118,7 @@ timeMs — timestamp клиента (UTC).
sessionPwd — строка с шага 1.
signatureB64 — Ed25519подпись preimage приватным ключом deviceKey.
signatureB64 — Ed25519подпись preimage приватным ключом clientKey.
📤 Запрос клиента
{
@ -141,7 +141,7 @@ signatureB64 — Ed25519подпись preimage приватным ключо
Восстанавливает preimage.
Находит deviceKey пользователя.
Находит clientKey пользователя.
Проверяет Ed25519-подпись.

View File

@ -3,13 +3,13 @@ package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey.
* Шаг 2 (v2): создание новой сессии ТОЛЬКО через clientKey.
*
* Шаги:
* 1) AuthChallenge(login) -> authNonce
* 2) CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, deviceKey, signatureB64, clientInfo)
* 2) CreateAuthSession(login, sessionKey, storagePwd, timeMs, authNonce, clientKey, signatureB64, clientInfo)
*
* Подпись deviceKey делается над строкой (UTF-8):
* Подпись clientKey делается над строкой (UTF-8):
* AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce}
*
* Важно:
@ -33,9 +33,9 @@ public class Net_CreateAuthSession_Request extends Net_Request {
private String authNonce;
/** Публичный ключ устройства пользователя. */
private String deviceKey;
private String clientKey;
/** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */
/** Подпись Ed25519(clientKey) над строкой AUTH_CREATE_SESSION:... (base64). */
private String signatureB64;
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
@ -87,12 +87,12 @@ public class Net_CreateAuthSession_Request extends Net_Request {
this.authNonce = authNonce;
}
public String getDeviceKey() {
return deviceKey;
public String getClientKey() {
return clientKey;
}
public void setDeviceKey(String deviceKey) {
this.deviceKey = deviceKey;
public void setClientKey(String clientKey) {
this.clientKey = clientKey;
}
public String getSignatureB64() {

View File

@ -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)"

View File

@ -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)"

View File

@ -1,180 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос GetFriendsLists — получить два списка "друзей" по connections_state.
*
* {
* "op": "GetFriendsLists",
* "requestId": "req-100",
* "payload": {
* "login": "anya"
* }
* }
*
* Возвращает:
* - out_friends: кому login поставил FRIEND
* - in_friends: кто поставил FRIEND этому login
*
* ПРО ДОСТУП (на будущее):
* Сейчас (MVP) без ограничений. Позже можно ограничить видимость связей.
*/
public class Net_GetFriendsLists_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
/**
* Ответ GetFriendsLists.
*
* {
* "op": "GetFriendsLists",
* "requestId": "req-100",
* "status": 200,
* "payload": {
* "login": "Anya", // канонический регистр из БД
* "out_friends": ["Bob", "Kate"], // кому login поставил FRIEND
* "in_friends": ["Alex", "Kate"] // кто поставил FRIEND login
* }
* }
*/
public class Net_GetFriendsLists_Response extends Net_Response {
private String login;
private List<String> out_friends = new ArrayList<>();
private List<String> in_friends = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<String> getOut_friends() { return out_friends; }
public void setOut_friends(List<String> out_friends) { this.out_friends = out_friends; }
public List<String> getIn_friends() { return in_friends; }
public void setIn_friends(List<String> in_friends) { this.in_friends = in_friends; }
}
package server.logic.ws_protocol.JSON.handlers.connections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriendsLists_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.SqliteDbController;
import shine.db.dao.ConnectionsStateDAO;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.List;
/**
* GetFriendsLists — получить 2 списка:
* - out_friends: кому login поставил FRIEND
* - in_friends: кто поставил FRIEND этому login
*
* ВАЖНО:
* - login в запросе может быть любым регистром
* - в ответе возвращаем канонический регистр (как в solana_users.login)
*
* ПРИМЕЧАНИЕ:
* Таблица пользователей тут названа "solana_users". Если у тебя иначе — поменяй SQL.
*/
public class Net_GetFriendsLists_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetFriendsLists_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetFriendsLists_Request req = (Net_GetFriendsLists_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login"
);
}
final String loginAnyCase = req.getLogin().trim();
try {
SqliteDbController db = SqliteDbController.getInstance();
ConnectionsStateDAO dao = ConnectionsStateDAO.getInstance();
try (Connection c = db.getConnection()) {
// 1) Канонизируем login через solana_users (NOCASE)
String canonicalLogin = findCanonicalLogin(c, loginAnyCase);
if (canonicalLogin == null) {
return NetExceptionResponseFactory.error(
req,
404,
"USER_NOT_FOUND",
"Пользователь не найден"
);
}
int relType = (int) MsgSubType.CONNECTION_FRIEND;
// 2) Два списка (логины канонические)
List<String> outFriends = dao.listOutgoingByRelTypeCanonical(c, canonicalLogin, relType);
List<String> inFriends = dao.listIncomingByRelTypeCanonical(c, canonicalLogin, relType);
Net_GetFriendsLists_Response resp = new Net_GetFriendsLists_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(canonicalLogin);
resp.setOut_friends(outFriends);
resp.setIn_friends(inFriends);
return resp;
}
} catch (Exception e) {
log.error("❌ Internal error GetFriendsLists", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
private String findCanonicalLogin(Connection c, String loginAnyCase) throws Exception {
String sql = """
SELECT login
FROM solana_users
WHERE login = ? COLLATE NOCASE
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, loginAnyCase);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return rs.getString("login");
}
}
}
}

View File

@ -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)"

View File

@ -62,7 +62,7 @@ public class Net_GetUser_Handler implements JsonMessageHandler {
resp.setBlockchainName(u.getBlockchainName());
resp.setSolanaKey(u.getSolanaKey());
resp.setBlockchainKey(u.getBlockchainKey());
resp.setDeviceKey(u.getDeviceKey());
resp.setClientKey(u.getClientKey());
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
// автоматически восстанавливаем её для существующего пользователя.

View File

@ -1,240 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос AddUser — временная/тестовая регистрация локального пользователя.
*
* Клиент отправляет:
*
* {
* "op": "AddUser",
* "requestId": "test-add-1",
* "payload": {
* "login": "anya",
* "blockchainName": "anya-001",
* "solanaKey": "base64-ed25519-public-key-login",
* "blockchainKey": "base64-ed25519-public-key-blockchain",
* "deviceKey": "base64-ed25519-public-key-device",
* "bchLimit": 1000000
* }
* }
*
* Все поля лежат внутри payload.
*/
public class Net_AddUser_Request extends Net_Request {
private String login;
private String blockchainName;
/** Ключ пользователя Solana (публичный ключ логина) */
private String solanaKey;
/** Ключ блокчейна (публичный ключ блокчейна) */
private String blockchainKey;
/** Ключ устройства (публичный ключ устройства) */
private String deviceKey;
private Integer bchLimit;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public String getSolanaKey() { return solanaKey; }
public void setSolanaKey(String solanaKey) { this.solanaKey = solanaKey; }
public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
public String getDeviceKey() { return deviceKey; }
public void setDeviceKey(String deviceKey) { this.deviceKey = deviceKey; }
public Integer getBchLimit() { return bchLimit; }
public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; }
}
package server.logic.ws_protocol.JSON.handlers.tempToTest.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Успешный ответ на AddUser.
*
* Сейчас дополнительных полей нет — достаточно status=200.
*
* Пример:
* {
* "op": "AddUser",
* "requestId": "test-add-1",
* "status": 200,
* "payload": { }
* }
*/
public class Net_AddUser_Response extends Net_Response {
// При необходимости сюда можно добавить, например, флаг created/updated и т.п.
}
package server.logic.ws_protocol.JSON.handlers.tempToTest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_AddUser_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.SolanaUsersDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserEntry;
import utils.blockchain.BlockchainNameUtil;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Base64;
public class Net_AddUser_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_AddUser_Handler.class);
/** TEST ONLY */
private static final int TEST_BCH_LIMIT = 1_000_000;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_AddUser_Request req = (Net_AddUser_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getBlockchainName() == null || req.getBlockchainName().isBlank()
|| req.getSolanaKey() == null || req.getSolanaKey().isBlank()
|| req.getBlockchainKey() == null || req.getBlockchainKey().isBlank()
|| req.getDeviceKey() == null || req.getDeviceKey().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/blockchainName/solanaKey/blockchainKey/deviceKey"
);
}
// blockchainName должен быть вида: <login>-NNN
if (!BlockchainNameUtil.isBlockchainNameMatchesLogin(req.getBlockchainName(), req.getLogin())) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BLOCKCHAIN_NAME",
"blockchainName должен быть вида <login>-NNN (пример: anya-001)"
);
}
int limit = (req.getBchLimit() == null || req.getBchLimit() <= 0)
? TEST_BCH_LIMIT
: req.getBchLimit();
try {
byte[] blockchainKey32 = Base64.getDecoder().decode(req.getBlockchainKey());
if (blockchainKey32.length != 32) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BLOCKCHAIN_KEY",
"blockchainKey должен быть Base64(32 bytes)"
);
}
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
SqliteDbController db = SqliteDbController.getInstance();
try (Connection c = db.getConnection()) {
c.setAutoCommit(false);
// 1. Проверяем, что пользователя нет
if (usersDAO.getByLogin(req.getLogin()) != null) {
return NetExceptionResponseFactory.error(
req,
409,
"USER_ALREADY_EXISTS",
"Пользователь с таким login уже существует"
);
}
// 2. Проверяем, что blockchain_state ещё нет
if (stateDAO.getByBlockchainName(req.getBlockchainName()) != null) {
return NetExceptionResponseFactory.error(
req,
409,
"BLOCKCHAIN_ALREADY_EXISTS",
"blockchain_state уже существует"
);
}
// 3. Создаём пользователя (solanaKey + deviceKey)
SolanaUserEntry user = new SolanaUserEntry(
req.getLogin(),
req.getSolanaKey(),
req.getDeviceKey()
);
usersDAO.insert(c, user);
// 4. Создаём INITIAL blockchain_state (blockchainKey)
BlockchainStateEntry st = new BlockchainStateEntry();
st.setBlockchainName(req.getBlockchainName());
st.setLogin(req.getLogin());
st.setBlockchainKey(req.getBlockchainKey()); // Base64(32)
st.setLastBlockNumber(-1);
st.setLastBlockHash(new byte[32]);
st.setFileSizeBytes(0);
st.setSizeLimit(limit);
st.setUpdatedAtMs(System.currentTimeMillis());
stateDAO.upsert(c, st);
c.commit();
}
Net_AddUser_Response resp = new Net_AddUser_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
log.info("✅ AddUser ok: login={}, blockchainName={}, limit={}",
req.getLogin(), req.getBlockchainName(), limit);
return resp;
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_KEY_FORMAT",
e.getMessage()
);
} catch (SQLException e) {
log.error("❌ DB error AddUser", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"DB_ERROR",
"Ошибка БД"
);
} catch (Exception e) {
log.error("❌ Internal error AddUser", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
}

View File

@ -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)"

View File

@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
* "blockchainName": "anya-001",
* "solanaKey": "base64-ed25519-public-key-login",
* "blockchainKey": "base64-ed25519-public-key-blockchain",
* "deviceKey": "base64-ed25519-public-key-device",
* "clientKey": "base64-ed25519-public-key-device",
* "bchLimit": 1000000
* }
* }
@ -34,7 +34,7 @@ public class Net_AddUser_Request extends Net_Request {
private String blockchainKey;
/** Ключ устройства (публичный ключ устройства) */
private String deviceKey;
private String clientKey;
private Integer bchLimit;
@ -50,8 +50,8 @@ public class Net_AddUser_Request extends Net_Request {
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; }
public Integer getBchLimit() { return bchLimit; }
public void setBchLimit(Integer bchLimit) { this.bchLimit = bchLimit; }

View File

@ -26,7 +26,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
* "blockchainName": "anya-001",
* "solanaKey": "...",
* "blockchainKey": "...",
* "deviceKey": "..."
* "clientKey": "..."
* }
* }
*/
@ -38,7 +38,7 @@ public class Net_GetUser_Response extends Net_Response {
private String blockchainName;
private String solanaKey;
private String blockchainKey;
private String deviceKey;
private String clientKey;
private Integer serverLastGlobalNumber;
private String serverLastGlobalHash;
private Long serverBlockchainSizeBytes;
@ -59,8 +59,8 @@ public class Net_GetUser_Response extends Net_Response {
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; }
public Integer getServerLastGlobalNumber() { return serverLastGlobalNumber; }
public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { this.serverLastGlobalNumber = serverLastGlobalNumber; }

View File

@ -71,7 +71,7 @@ public class Net_GetUserParam_Handler implements JsonMessageHandler {
resp.setParam(e.getParam());
resp.setTime_ms(e.getTimeMs());
resp.setValue(e.getValue());
resp.setDevice_key(e.getDeviceKey());
resp.setClient_key(e.getClientKey());
resp.setSignature(e.getSignature());
return resp;

View File

@ -73,7 +73,7 @@ public class Net_ListUserParams_Handler implements JsonMessageHandler {
it.setParam(e.getParam());
it.setTime_ms(e.getTimeMs());
it.setValue(e.getValue());
it.setDevice_key(e.getDeviceKey());
it.setClient_key(e.getClientKey());
it.setSignature(e.getSignature());
items.add(it);
}

View File

@ -28,8 +28,8 @@ import java.sql.SQLException;
*
* Делает (MVP, без "сессий"):
* 1) Проверка входных полей.
* 2) Проверка подписи Ed25519 по device_key.
* 3) Проверка, что пользователь существует и что device_key принадлежит этому login.
* 2) Проверка подписи Ed25519 по client_key.
* 3) Проверка, что пользователь существует и что client_key принадлежит этому login.
* 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE).
*
* ВАЖНО:
@ -50,14 +50,14 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
|| req.getParam() == null || req.getParam().isBlank()
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|| req.getValue() == null
|| req.getDevice_key() == null || req.getDevice_key().isBlank()
|| req.getClient_key() == null || req.getClient_key().isBlank()
|| req.getSignature() == null || req.getSignature().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/param/time_ms/value/device_key/signature"
"Некорректные поля: login/param/time_ms/value/client_key/signature"
);
}
@ -65,7 +65,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
final String param = req.getParam().trim();
final long timeMs = req.getTime_ms();
final String value = req.getValue();
final String deviceKeyB64 = req.getDevice_key().trim();
final String clientKeyB64 = req.getClient_key().trim();
final String signatureB64 = req.getSignature().trim();
try {
@ -73,14 +73,14 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
byte[] pubKey32;
byte[] sig64;
try {
pubKey32 = Base64Ws.decodeLen(deviceKeyB64, 32, "device_key");
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BASE64",
"device_key/signature должны быть Base64"
"client_key/signature должны быть Base64"
);
}
@ -120,23 +120,23 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
);
}
// 2) device key must match the user's stored deviceKey
String userDeviceKey = user.getDeviceKey();
if (userDeviceKey == null || userDeviceKey.isBlank()) {
// 2) client key must match the user's stored clientKey
String userClientKey = user.getClientKey();
if (userClientKey == null || userClientKey.isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"USER_DEVICE_KEY_EMPTY",
"У пользователя не задан deviceKey в БД"
"У пользователя не задан clientKey в БД"
);
}
if (!userDeviceKey.trim().equals(deviceKeyB64)) {
if (!userClientKey.trim().equals(clientKeyB64)) {
return NetExceptionResponseFactory.error(
req,
403,
"DEVICE_KEY_MISMATCH",
"device_key не соответствует пользователю"
"client_key не соответствует пользователю"
);
}
@ -146,7 +146,7 @@ public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
param,
timeMs,
value,
deviceKeyB64,
clientKeyB64,
signatureB64
);

View File

@ -1,640 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос GetUserParam — получить один параметр пользователя.
*
* {
* "op": "GetUserParam",
* "requestId": "req-1",
* "payload": {
* "login": "anya",
* "param": "feed:lastSeenGlobal"
* }
* }
*
* ПРО ДОСТУП (на будущее):
* ---------------------------------------------------------------------------------
* Сейчас (MVP) этот запрос не ограничивает просмотр параметров, т.к. проект в тестовом режиме.
* Позже, вероятно, потребуется ограничить: кто и какие параметры может читать (сессия/права).
* Но для MVP эти проверки не нужны.
* ---------------------------------------------------------------------------------
*/
public class Net_GetUserParam_Request extends Net_Request {
private String login;
private String param;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Ответ GetUserParam.
*
* Если найден:
* {
* "op": "GetUserParam",
* "requestId": "req-1",
* "status": 200,
* "payload": {
* "login": "anya",
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-32",
* "signature": "base64-64"
* }
* }
*
* Если не найден:
* status=404, payload пустой.
*/
public class Net_GetUserParam_Response extends Net_Response {
private String login;
private String param;
private Long time_ms;
private String value;
private String device_key;
private String signature;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос ListUserParams — получить все сохранённые параметры пользователя.
*
* {
* "op": "ListUserParams",
* "requestId": "req-2",
* "payload": {
* "login": "anya"
* }
* }
*
* ПРО ДОСТУП (на будущее):
* ---------------------------------------------------------------------------------
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
* Для MVP эти проверки не нужны.
* ---------------------------------------------------------------------------------
*/
public class Net_ListUserParams_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
/**
* Ответ ListUserParams — список всех параметров пользователя.
*
* {
* "op": "ListUserParams",
* "requestId": "req-2",
* "status": 200,
* "payload": {
* "login": "anya",
* "params": [
* {
* "login": "anya",
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-32",
* "signature": "base64-64"
* },
* ...
* ]
* }
* }
*/
public class Net_ListUserParams_Response extends Net_Response {
private String login;
private List<Item> params = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<Item> getParams() { return params; }
public void setParams(List<Item> params) { this.params = params; }
public static class Item {
private String login;
private String param;
private Long time_ms;
private String value;
private String device_key;
private String signature;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запрос UpsertUserParam — добавить/обновить сохранённый параметр пользователя.
*
* Клиент отправляет:
*
* {
* "op": "UpsertUserParam",
* "requestId": "req-123",
* "payload": {
* "login": "anya",
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-ed25519-public-key-32",
* "signature": "base64-ed25519-signature-64"
* }
* }
*
* Подпись считается от UTF-8 строки:
* USER_PARAMETER_PREFIX + login + param + time_ms + value
*/
public class Net_UpsertUserParam_Request extends Net_Request {
private String login;
private String param;
private Long time_ms;
private String value;
private String device_key;
private String signature;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getParam() { return param; }
public void setParam(String param) { this.param = param; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
package server.logic.ws_protocol.JSON.handlers.userParams.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/**
* Ответ на UpsertUserParam.
*
* Успех:
* {
* "op": "UpsertUserParam",
* "requestId": "req-123",
* "status": 200,
* "payload": { }
* }
*/
public class Net_UpsertUserParam_Response extends Net_Response {
// MVP: без payload. При желании позже можно добавить created/updated.
}
package server.logic.ws_protocol.JSON.handlers.userParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_GetUserParam_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.UserParamsDAO;
import shine.db.entities.UserParamEntry;
import java.sql.Connection;
/**
* GetUserParam — получить один параметр пользователя.
*
* ПРО ДОСТУП (на будущее):
* ---------------------------------------------------------------------------------
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
* Для MVP эти проверки не нужны.
* ---------------------------------------------------------------------------------
*/
public class Net_GetUserParam_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetUserParam_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetUserParam_Request req = (Net_GetUserParam_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getParam() == null || req.getParam().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/param"
);
}
String login = req.getLogin().trim();
String param = req.getParam().trim();
try {
SqliteDbController db = SqliteDbController.getInstance();
UserParamsDAO dao = UserParamsDAO.getInstance();
try (Connection c = db.getConnection()) {
UserParamEntry e = dao.getByLoginAndParam(c, login, param);
if (e == null) {
Net_GetUserParam_Response resp = new Net_GetUserParam_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(404);
return resp;
}
Net_GetUserParam_Response resp = new Net_GetUserParam_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(e.getLogin());
resp.setParam(e.getParam());
resp.setTime_ms(e.getTimeMs());
resp.setValue(e.getValue());
resp.setDevice_key(e.getDeviceKey());
resp.setSignature(e.getSignature());
return resp;
}
} catch (Exception e) {
log.error("❌ Internal error GetUserParam", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
}
package server.logic.ws_protocol.JSON.handlers.userParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_ListUserParams_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.UserParamsDAO;
import shine.db.entities.UserParamEntry;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
/**
* ListUserParams — получить все параметры пользователя.
*
* ПРО ДОСТУП (на будущее):
* ---------------------------------------------------------------------------------
* Сейчас (MVP) запрос не ограничивает просмотр параметров.
* В будущем, вероятно, потребуется проверка сессии/прав: кто может читать параметры.
* Для MVP эти проверки не нужны.
* ---------------------------------------------------------------------------------
*/
public class Net_ListUserParams_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_ListUserParams_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_ListUserParams_Request req = (Net_ListUserParams_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login"
);
}
String login = req.getLogin().trim();
try {
SqliteDbController db = SqliteDbController.getInstance();
UserParamsDAO dao = UserParamsDAO.getInstance();
List<UserParamEntry> entries;
try (Connection c = db.getConnection()) {
entries = dao.getByLogin(c, login);
}
Net_ListUserParams_Response resp = new Net_ListUserParams_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
List<Net_ListUserParams_Response.Item> items = new ArrayList<>();
for (UserParamEntry e : entries) {
Net_ListUserParams_Response.Item it = new Net_ListUserParams_Response.Item();
it.setLogin(e.getLogin());
it.setParam(e.getParam());
it.setTime_ms(e.getTimeMs());
it.setValue(e.getValue());
it.setDevice_key(e.getDeviceKey());
it.setSignature(e.getSignature());
items.add(it);
}
resp.setParams(items);
return resp;
} catch (Exception e) {
log.error("❌ Internal error ListUserParams", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
}
package server.logic.ws_protocol.JSON.handlers.userParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Request;
import server.logic.ws_protocol.JSON.handlers.userParams.entyties.Net_UpsertUserParam_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.dao.SolanaUsersDAO;
import shine.db.dao.UserParamsDAO;
import shine.db.entities.SolanaUserEntry;
import shine.db.entities.UserParamEntry;
import utils.config.ShineSignatureConstants;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Base64;
/**
* Net_UpsertUserParam_Handler
*
* Делает (MVP, без "сессий"):
* 1) Проверка входных полей.
* 2) Проверка подписи Ed25519 по device_key.
* 3) Проверка, что пользователь существует и что device_key принадлежит этому login.
* 4) Атомарная запись в БД "только если time_ms новее" (UPSERT + WHERE).
*
* ВАЖНО:
* - НИКАКИХ ручных транзакций / BEGIN здесь нет.
* - autoCommit=true, каждый statement завершённый сам по себе.
* - Гонки не страшны: если за время проверок кто-то записал более новый time_ms,
* наш финальный UPSERT просто вернёт 0 обновлённых строк.
*/
public class Net_UpsertUserParam_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_UpsertUserParam_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_UpsertUserParam_Request req = (Net_UpsertUserParam_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getParam() == null || req.getParam().isBlank()
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|| req.getValue() == null
|| req.getDevice_key() == null || req.getDevice_key().isBlank()
|| req.getSignature() == null || req.getSignature().isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS",
"Некорректные поля: login/param/time_ms/value/device_key/signature"
);
}
final String login = req.getLogin().trim();
final String param = req.getParam().trim();
final long timeMs = req.getTime_ms();
final String value = req.getValue();
final String deviceKeyB64 = req.getDevice_key().trim();
final String signatureB64 = req.getSignature().trim();
try {
// ---------------- Base64 decode ----------------
byte[] pubKey32;
byte[] sig64;
try {
pubKey32 = Base64.getDecoder().decode(deviceKeyB64);
sig64 = Base64.getDecoder().decode(signatureB64);
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_BASE64",
"device_key/signature должны быть Base64"
);
}
if (pubKey32.length != 32) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_DEVICE_KEY",
"device_key должен быть Base64(32 bytes)"
);
}
if (sig64.length != 64) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.BAD_REQUEST,
"BAD_SIGNATURE",
"signature должна быть Base64(64 bytes)"
);
}
// ---------------- Signature verify ----------------
String signText = ShineSignatureConstants.USER_PARAMETER_PREFIX
+ login
+ param
+ timeMs
+ value;
byte[] signBytes = signText.getBytes(StandardCharsets.UTF_8);
boolean sigOk = Ed25519Util.verify(signBytes, sig64, pubKey32);
if (!sigOk) {
return NetExceptionResponseFactory.error(
req,
403,
"SIGNATURE_INVALID",
"Подпись не прошла проверку"
);
}
// ---------------- DB checks + upsert ----------------
SqliteDbController db = SqliteDbController.getInstance();
SolanaUsersDAO usersDAO = SolanaUsersDAO.getInstance();
UserParamsDAO paramsDAO = UserParamsDAO.getInstance();
try (Connection c = db.getConnection()) {
// 1) user exists
SolanaUserEntry user = usersDAO.getByLogin(c, login);
if (user == null) {
return NetExceptionResponseFactory.error(
req,
404,
"USER_NOT_FOUND",
"Пользователь не найден"
);
}
// 2) device key must match the user's stored deviceKey
String userDeviceKey = user.getDeviceKey();
if (userDeviceKey == null || userDeviceKey.isBlank()) {
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"USER_DEVICE_KEY_EMPTY",
"У пользователя не задан deviceKey в БД"
);
}
if (!userDeviceKey.trim().equals(deviceKeyB64)) {
return NetExceptionResponseFactory.error(
req,
403,
"DEVICE_KEY_MISMATCH",
"device_key не соответствует пользователю"
);
}
// 3) atomic upsert-if-newer
UserParamEntry e = new UserParamEntry(
login,
param,
timeMs,
value,
deviceKeyB64,
signatureB64
);
int changed = paramsDAO.upsertIfNewer(c, e);
Net_UpsertUserParam_Response resp = new Net_UpsertUserParam_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
if (changed == 1) {
log.info("✅ UpsertUserParam applied: login={}, param={}, time_ms={}", login, param, timeMs);
} else {
// 0 строк — значит в БД уже есть time_ms >= incoming
log.info(" UpsertUserParam ignored (not newer): login={}, param={}, time_ms={}", login, param, timeMs);
}
return resp;
}
} catch (SQLException e) {
log.error("❌ DB error UpsertUserParam", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.SERVER_DATA_ERROR,
"DB_ERROR",
"Ошибка БД"
);
} catch (Exception e) {
log.error("❌ Internal error UpsertUserParam", e);
return NetExceptionResponseFactory.error(
req,
WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR",
"Внутренняя ошибка сервера"
);
}
}
}

View File

@ -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)"

View File

@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-32",
* "client_key": "base64-32",
* "signature": "base64-64"
* }
* }
@ -29,7 +29,7 @@ public class Net_GetUserParam_Response extends Net_Response {
private String param;
private Long time_ms;
private String value;
private String device_key;
private String client_key;
private String signature;
public String getLogin() { return login; }
@ -44,8 +44,8 @@ public class Net_GetUserParam_Response extends Net_Response {
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getClient_key() { return client_key; }
public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }

View File

@ -20,7 +20,7 @@ import java.util.List;
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-32",
* "client_key": "base64-32",
* "signature": "base64-64"
* },
* ...
@ -44,7 +44,7 @@ public class Net_ListUserParams_Response extends Net_Response {
private String param;
private Long time_ms;
private String value;
private String device_key;
private String client_key;
private String signature;
public String getLogin() { return login; }
@ -59,8 +59,8 @@ public class Net_ListUserParams_Response extends Net_Response {
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getClient_key() { return client_key; }
public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }

View File

@ -15,7 +15,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
* "param": "feed:lastSeenGlobal",
* "time_ms": 1736000000123,
* "value": "105",
* "device_key": "base64-ed25519-public-key-32",
* "client_key": "base64-ed25519-public-key-32",
* "signature": "base64-ed25519-signature-64"
* }
* }
@ -30,7 +30,7 @@ public class Net_UpsertUserParam_Request extends Net_Request {
private Long time_ms;
private String value;
private String device_key;
private String client_key;
private String signature;
public String getLogin() { return login; }
@ -45,8 +45,8 @@ public class Net_UpsertUserParam_Request extends Net_Request {
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
public String getDevice_key() { return device_key; }
public void setDevice_key(String device_key) { this.device_key = device_key; }
public String getClient_key() { return client_key; }
public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }

View File

@ -62,9 +62,9 @@ public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
byte[] publicKey32;
try {
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getDeviceKey());
publicKey32 = Ed25519Util.keyFromBase64(fromUser.getClientKey());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный deviceKey отправителя");
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_DEVICE_KEY", "Некорректный clientKey отправителя");
}
if (!Ed25519Util.verify(packet.signedBody, packet.signature64, publicKey32)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "BAD_SIGNATURE", "Подпись не прошла проверку");

View File

@ -44,7 +44,7 @@ final class SignedMessagesCore {
if (from == null || to == null) {
throw new IllegalArgumentException("USER_NOT_FOUND");
}
byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getDeviceKey());
byte[] pubKey32 = Ed25519Util.keyFromBase64(from.getClientKey());
if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) {
throw new IllegalArgumentException("BAD_SIGNATURE");
}

View File

@ -31,7 +31,7 @@
// }
//
// /**
// * Проверка подписи CreateAuthSession(v2) по deviceKey пользователя.
// * Проверка подписи CreateAuthSession(v2) по clientKey пользователя.
// * Подпись проверяется над preimageCreateAuthSession(...).
// */
// public static boolean verifyCreateAuthSessionSignature(
@ -42,8 +42,8 @@
// String signatureB64
// ) throws IllegalArgumentException {
//
// // user.getDeviceKey() base64 публичного ключа (32 байта)
// byte[] publicKey32 = decodeBase64Any(user.getDeviceKey());
// // user.getClientKey() base64 публичного ключа (32 байта)
// byte[] publicKey32 = decodeBase64Any(user.getClientKey());
// byte[] signature64 = decodeBase64Any(signatureB64);
//
// byte[] preimage = preimageCreateAuthSession(login, timeMs, authNonce);

View File

@ -1,552 +0,0 @@
package server.logic;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.binary.handlers.*;
import server.logic.ws_protocol.WireCodes;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Map;
/**
* Обработчик входящих сообщение на сервер.
* По коду сообщения (первые 4 байта сообщения) находи нужный хэндлер и передаёт в него сообщение
* Получает и возвращает ответ от хэндлера
*/
public final class InboundMessageProcessor {
private static final Logger log = LoggerFactory.getLogger(InboundMessageProcessor.class);
private static final Map<Integer, MessageHandler> HANDLERS = Map.of(
// WireCodes.Op.PING, new PingHandler()
// WireCodes.Op.ADD_BLOCK, new AddBlockHandler(),
// WireCodes.Op.GET_BLOCKCHAIN,new GetBlockchainHandler()
// WireCodes.Op.SEARCH_USERS, new SearchUsersHandler(),
// WireCodes.Op.GET_LAST_BLOCK_INFO,new GetLastBlockInfoHandler()
);
private InboundMessageProcessor() {}
public static byte[] process(byte[] msg) {
if (msg == null || msg.length < 4)
return intTo4Bytes(WireCodes.Status.BAD_REQUEST);
int op = first4ToInt(msg);
MessageHandler h = HANDLERS.get(op);
if (h == null) {
log.warn("Неизвестная операция: {}", op);
return intTo4Bytes(WireCodes.Status.BAD_REQUEST);
}
try {
return h.handle(msg);
} catch (Exception e) {
log.error("Ошибка при обработке операции {}", op, e);
return intTo4Bytes(WireCodes.Status.INTERNAL_ERROR);
}
}
private static int first4ToInt(byte[] msg) {
return ByteBuffer.wrap(msg, 0, 4)
.order(ByteOrder.BIG_ENDIAN)
.getInt();
}
public static byte[] intTo4Bytes(int code) {
return ByteBuffer.allocate(4)
.order(ByteOrder.BIG_ENDIAN)
.putInt(code)
.array();
}
}
package server.logic.ws_protocol.binary.handlers;
/**
* Общий интерфейс для всех обработчиков входящих сообщений.
*/
public interface MessageHandler {
/**
* Обработать входящее сообщение и вернуть бинарный ответ.
*/
byte[] handle(byte[] msg);
}
package server.ws;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.BlockchainStateDAO;
import shine.db.entities.BlockchainStateEntry;
import utils.files.FileStoreUtil;
import shine.log.BlockchainAdminNotifier;
import java.io.IOException;
import java.nio.file.*;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* ===============================================================
* BlockchainTmpRecoveryOnStartup — восстановление консистентности
* blockchain файлов при старте сервера.
*
* Сценарий проблемы:
* - при добавлении блока сначала пишется <name>.tmp_bch
* - потом коммитится БД (state.fileSizeBytes)
* - потом tmp переименовывается поверх <name>.bch (атомарно, если возможно)
*
* Если сервер упал в середине, может остаться tmp:
* - tmp есть, а основной .bch остался старым
* - tmp есть, а основной .bch уже удалили/заменить не успели
* - tmp есть, а БД успела/не успела обновиться
*
* Этот класс при старте:
* - ищет все *.tmp_bch в data/
* - сравнивает размеры:
* - tmp
* - main (если есть)
* - state.fileSizeBytes (если есть)
*
* Правила:
*
* A) state есть:
* - если stateSize == mainSize => tmp удаляем
* - если stateSize == tmpSize => tmp ставим на место main (atomicReplaceBlockchainFile)
* - иначе => КРИТИЧЕСКАЯ ОШИБКА: сервер останавливаем + уведомление администратору
*
* B) state НЕТ:
* - если main НЕТ и tmp ЕСТЬ => tmp удаляем (мусор после падения/неуспешной транзакции)
* - если main ЕСТЬ и tmp ЕСТЬ => КРИТИЧЕСКАЯ ОШИБКА: уведомление администратору + стоп сервера
*
* Логирование:
* - обо всех восстановленных/удалённых tmp пишем в лог
* - если tmp-файлов нет — тоже пишем в лог
* ===============================================================
*/
public final class BlockchainTmpRecoveryOnStartup {
private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class);
private BlockchainTmpRecoveryOnStartup() {}
/**
* Запуск восстановления.
* Если обнаружена ситуация, когда размеры не совпали и сервер сам не может чинить — бросаем исключение.
*/
public static void runRecoveryOrThrow() {
FileStoreUtil fs = FileStoreUtil.getInstance();
BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME);
ensureDirExists(dataDir);
List<Path> tmpFiles = listTmpFiles(dataDir);
if (tmpFiles.isEmpty()) {
log.info("🟢 BlockchainTmpRecovery: временных *.tmp_bch файлов не найдено — восстановление не требуется.");
return;
}
log.warn("🟡 BlockchainTmpRecovery: найдено временных файлов: {}", tmpFiles.size());
for (Path tmpPath : tmpFiles) {
String fileName = tmpPath.getFileName().toString();
String blockchainName = extractBlockchainNameFromTmp(fileName);
if (blockchainName == null || blockchainName.isBlank()) {
// странное имя — не трогаем автоматически, но это уже повод дернуть админа
BlockchainAdminNotifier.critical(
"НАЙДЕН TMP-ФАЙЛ С НЕОЖИДАННЫМ ИМЕНЕМ: " + fileName + " (не могу определить blockchainName).",
null
);
throw new IllegalStateException("Bad tmp file name: " + fileName);
}
Path mainPath = dataDir.resolve(fs.buildBlockchainFileName(blockchainName));
long tmpSize = safeSize(tmpPath);
boolean mainExists = Files.exists(mainPath);
long mainSize = mainExists ? safeSize(mainPath) : -1L;
BlockchainStateEntry st = null;
try {
st = stateDAO.getByBlockchainName(blockchainName);
} catch (SQLException e) {
BlockchainAdminNotifier.critical(
"ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ TMP: blockchainName=" + blockchainName + " (сервер остановлен).",
e
);
throw new IllegalStateException("DB error during tmp recovery for " + blockchainName, e);
}
// ============================================================
// CASE B) state НЕТ
// ============================================================
if (st == null) {
if (!mainExists) {
// НЕТ state, НЕТ main, есть tmp => удаляем tmp
log.warn("🟠 BlockchainTmpRecovery: state отсутствует и main отсутствует, но tmp найден => удаляем tmp. blockchainName={}, tmpSize={}",
blockchainName, tmpSize);
safeDelete(tmpPath);
continue;
}
// НЕТ state, но main есть и tmp есть => это уже подозрительно
BlockchainAdminNotifier.critical(
"НЕСОГЛАСОВАННОСТЬ: ЕСТЬ main И tmp, НО НЕТ state В БД. " +
"blockchainName=" + blockchainName +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН. " +
"ПОДОЗРЕНИЕ: файлы могли быть изменены вне сервера.",
null
);
throw new IllegalStateException("State missing but both main and tmp exist for " + blockchainName);
}
// ============================================================
// CASE A) state ЕСТЬ
// ============================================================
long stateSize = st.getFileSizeBytes();
// 1) stateSize == mainSize => tmp мусор
if (mainExists && mainSize == stateSize) {
log.info("🟢 BlockchainTmpRecovery: stateSize совпадает с main => tmp удаляем. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
blockchainName, stateSize, mainSize, tmpSize);
safeDelete(tmpPath);
continue;
}
// 2) stateSize == tmpSize => tmp это актуальная версия, ставим на место main
if (tmpSize == stateSize) {
log.warn("🟡 BlockchainTmpRecovery: stateSize совпадает с tmp => восстанавливаем main из tmp. blockchainName={}, stateSize={}, mainSize={}, tmpSize={}",
blockchainName, stateSize, mainSize, tmpSize);
try {
// метод уже есть и делает move tmp->main с попыткой ATOMIC_MOVE
fs.atomicReplaceBlockchainFile(blockchainName);
// после move tmp должен исчезнуть сам (перемещён)
log.info("✅ BlockchainTmpRecovery: восстановление выполнено. blockchainName={}, newMainSize={}",
blockchainName, safeSize(mainPath));
} catch (Exception e) {
BlockchainAdminNotifier.critical(
"НЕ УДАЛОСЬ ВОССТАНОВИТЬ main ИЗ tmp (move failed). " +
"blockchainName=" + blockchainName +
", stateSize=" + stateSize +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН.",
e
);
throw new IllegalStateException("Cannot replace main from tmp for " + blockchainName, e);
}
continue;
}
// 3) НИЧЕГО НЕ СОВПАЛО => критическая ситуация
BlockchainAdminNotifier.critical(
"ФАТАЛЬНАЯ НЕСОГЛАСОВАННОСТЬ BLOCKCHAIN ФАЙЛОВ. " +
"blockchainName=" + blockchainName +
", stateSize=" + stateSize +
", mainExists=" + mainExists +
", mainSize=" + mainSize +
", tmpSize=" + tmpSize +
". СЕРВЕР ОСТАНОВЛЕН. " +
"ТУТ НУЖНО УВЕДОМЛЕНИЕ АДМИНИСТРАТОРУ: возможно файлы изменены вручную/другой программой.",
null
);
throw new IllegalStateException("Blockchain files mismatch for " + blockchainName);
}
log.info("✅ BlockchainTmpRecovery: обработка tmp-файлов завершена.");
}
/* ===================================================================== */
/* =============================== Helpers ============================== */
/* ===================================================================== */
private static void ensureDirExists(Path dir) {
try {
if (!Files.exists(dir)) {
Files.createDirectories(dir);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot create data dir: " + dir, e);
}
}
private static List<Path> listTmpFiles(Path dataDir) {
List<Path> out = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) {
for (Path p : ds) {
if (Files.isRegularFile(p)) out.add(p);
}
} catch (IOException e) {
throw new IllegalStateException("Cannot list tmp files in: " + dataDir, e);
}
return out;
}
/**
* Из "anya0001.tmp_bch" -> "anya0001"
*/
private static String extractBlockchainNameFromTmp(String tmpFileName) {
if (tmpFileName == null) return null;
if (!tmpFileName.endsWith(FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION)) return null;
String base = tmpFileName.substring(0, tmpFileName.length() - FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION.length());
// базовая защита: не допускаем слэши/.. даже если кто-то подложил файл
if (base.isBlank()) return null;
if (base.contains("/") || base.contains("\\") || base.contains("..")) return null;
return base;
}
private static long safeSize(Path p) {
try {
return Files.size(p);
} catch (IOException e) {
throw new IllegalStateException("Cannot read file size: " + p, e);
}
}
private static void safeDelete(Path p) {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
throw new IllegalStateException("Cannot delete file: " + p, e);
}
}
}
package server.ws;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WriteCallback;
import org.eclipse.jetty.websocket.api.annotations.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.InboundMessageProcessor;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.JsonInboundProcessor;
import java.nio.ByteBuffer;
import java.util.concurrent.CompletableFuture;
@WebSocket
public class BlockchainWsEndpoint {
private static final Logger log = LoggerFactory.getLogger(BlockchainWsEndpoint.class);
private Session session;
/** Контекст для текущего WebSocket-соединения. */
private final ConnectionContext connectionContext = new ConnectionContext();
@OnWebSocketConnect
public void onConnect(Session session) {
this.session = session;
// Привязываем WebSocket-сессию к ConnectionContext
connectionContext.setWsSession(session);
log.info("WS connected: {}", session.getRemoteAddress());
}
@OnWebSocketMessage
public void onBinary(byte[] payload, int offset, int length) {
byte[] msg = new byte[length];
System.arraycopy(payload, offset, msg, 0, length);
// Асинхронно обрабатываем входящее бинарное сообщение
CompletableFuture
.supplyAsync(() -> InboundMessageProcessor.process(msg))
.thenAccept(resp -> {
if (resp != null && session != null && session.isOpen()) {
session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("Failed to send response", x);
}
@Override
public void writeSuccess() {
log.debug("Response sent successfully");
}
});
}
})
.exceptionally(ex -> {
log.error("Processing failed", ex);
trySendCode(500);
return null;
});
}
private void trySendCode(int code) {
if (session != null && session.isOpen()) {
byte[] resp = InboundMessageProcessor.intTo4Bytes(code);
session.getRemote().sendBytes(ByteBuffer.wrap(resp), new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("Failed to send error code", x);
}
@Override
public void writeSuccess() {
log.debug("Error code {} sent", code);
}
});
}
}
@OnWebSocketClose
public void onClose(int statusCode, String reason) {
log.info("WS closed: {} {}", statusCode, reason);
// Удаляем это подключение из реестра активных соединений
ActiveConnectionsRegistry.getInstance().remove(connectionContext);
// На всякий случай очищаем контекст
connectionContext.reset();
}
@OnWebSocketError
public void onError(Throwable cause) {
log.error("WS error", cause);
}
// Обработка текстовых JSON-запросов
@OnWebSocketMessage
public void onText(String message) {
log.info("📥 Получено TEXT-сообщение от клиента: {}", message);
CompletableFuture
.supplyAsync(() -> JsonInboundProcessor.processJson(message, connectionContext))
.thenAccept(respJson -> {
if (respJson != null && session != null && session.isOpen()) {
log.info("📤 Отправляем ответ клиенту: {}", respJson);
session.getRemote().sendString(respJson, new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString());
}
@Override
public void writeSuccess() {
log.debug("✔ JSON-ответ успешно отправлен");
}
});
}
})
.exceptionally(ex -> {
log.error("❌ Ошибка при обработке JSON-сообщения", ex);
trySendJsonError();
return null;
});
}
private void trySendJsonError() {
if (session != null && session.isOpen()) {
String resp = "{\"op\":null,\"requestId\":null,\"status\":500,"
+ "\"payload\":{\"code\":\"INTERNAL_ERROR\",\"message\":\"Ошибка сервера\"}}";
log.info("📤 Отправляем клиенту ошибку JSON: {}", resp);
session.getRemote().sendString(resp, new WriteCallback() {
@Override
public void writeFailed(Throwable x) {
log.warn("⚠️ Не удалось отправить JSON-ответ клиенту: {}", x.toString());
}
@Override
public void writeSuccess() {
log.debug("✔ JSON-ошибка успешно отправлена");
}
});
}
}
}
package server.ws;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import utils.config.AppConfig;
import java.time.Duration;
/**
* WsServer — поднимает Jetty WS на /ws.
*
* ВАЖНО:
* - перед стартом сервера выполняем recovery tmp-блокчейнов.
* - если обнаружена несогласованность, которую сервер сам чинить не может —
* recovery бросает исключение и сервер не стартует.
*/
public final class WsServer {
private static final Logger log = LoggerFactory.getLogger(WsServer.class);
public static void main(String[] args) throws Exception {
// ============================================================
// 0) Восстановление консистентности blockchain файлов
// ============================================================
try {
BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow();
} catch (Exception e) {
// Уже должно быть “большое” уведомление через BlockchainAdminNotifier,
// но на всякий случай логируем ещё раз.
log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e);
throw e; // останавливаем запуск
}
// ============================================================
// 1) Настройки порта
// ============================================================
AppConfig config = AppConfig.getInstance();
int port = 7070;
try {
String portStr = config.getParam("server.port");
if (portStr != null && !portStr.isBlank()) {
port = Integer.parseInt(portStr.trim());
}
} catch (Exception e) {
log.info("Не удалось прочитать параметр server.port, используем порт по умолчанию {}", port);
}
// ============================================================
// 2) Запуск Jetty WS
// ============================================================
Server server = new Server(port);
ServletContextHandler context = new ServletContextHandler();
context.setContextPath("/");
server.setHandler(context);
// Инициализация контейнера WebSocket
JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> {
// Таймаут простоя соединения (Jetty 11 синтаксис)
wsContainer.setIdleTimeout(Duration.ofMinutes(5));
// Маппинг эндпоинта
wsContainer.addMapping("/ws", (req, resp) -> new BlockchainWsEndpoint());
});
server.start();
log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port);
server.join();
}
}

View File

@ -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)"

View File

@ -1,38 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
SKIPFILE="skip.txt"
# очищаем или создаём файл
: > "$OUTFILE"
# читаем список исключённых имён (без расширения) в массив
if [[ -f "$SKIPFILE" ]]; then
mapfile -t SKIP_LIST < "$SKIPFILE"
else
SKIP_LIST=()
fi
find . -type f -name "*.java" | sort | while read -r f; do
fname=$(basename "$f" .java) # имя файла без расширения
# проверяем, есть ли имя в списке исключений
skip=false
for skipf in "${SKIP_LIST[@]}"; do
if [[ "$fname" == "$skipf" ]]; then
skip=true
break
fi
done
if [[ "$skip" == true ]]; then
echo "Пропускаем $f"
continue
fi
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
done
echo "Готово! Все .java файлы собраны в $OUTFILE (кроме исключённых из $SKIPFILE)"

View File

@ -1,76 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# OUTFILE:
# - если пустая строка ("") -> в файл НЕ пишем, только в буфер
# - если не пустая -> пишем в файл + (если есть wl-copy) копируем в буфер
OUTFILE="all_files.txt"
# OUTFILE=""
# === НАСТРОЙКА: перечисляй тут пути (каталоги и/или конкретные файлы) ===
# - Если путь указывает на ФАЙЛ: берём его ВСЕГДА, даже если это не .java
# - Если путь указывает на КАТАЛОГ: рекурсивно берём только *.java внутри
# - Пустые строки игнорируются
TARGETS=(
#"./src/main/java"
# "./server"
# /home/ai/work/SHiNE/SHiNE-server/shine-server-blockchain
"/home/ai/work/SHiNE/SHiNE-server/shine-server-blockchain"
"/home/ai/work/SHiNE/SHiNE-server/shine-server-db"
)
RED=$'\033[0;31m'
RESET=$'\033[0m'
warn_red() {
echo "${RED}WARN:${RESET} $*" >&2
}
# временные файлы
TMP_LIST="$(mktemp)"
TMP_OUT="$(mktemp)"
trap 'rm -f "$TMP_LIST" "$TMP_OUT"' EXIT
# собрать пути
for path in "${TARGETS[@]}"; do
path="$(printf '%s' "$path" | sed -e 's/^[[:space:]]\+//' -e 's/[[:space:]]\+$//')"
[[ -z "$path" ]] && continue
if [[ -f "$path" ]]; then
printf '%s\n' "$path" >> "$TMP_LIST"
elif [[ -d "$path" ]]; then
find "$path" -type f -name "*.java" >> "$TMP_LIST"
else
warn_red "Не найдено (пропускаю): $path"
fi
done
# склеиваем в TMP_OUT
sort -u "$TMP_LIST" | while IFS= read -r f; do
if [[ ! -f "$f" ]]; then
warn_red "Файл исчез (пропускаю): $f"
continue
fi
cat "$f" >> "$TMP_OUT"
echo >> "$TMP_OUT"
done
# если OUTFILE не пуст — пишем файл
if [[ -n "${OUTFILE:-}" ]]; then
: > "$OUTFILE"
cat "$TMP_OUT" > "$OUTFILE"
fi
# копирование в буфер (Wayland), если доступно
if command -v wl-copy >/dev/null 2>&1; then
wl-copy < "$TMP_OUT"
else
warn_red "wl-copy не найден — в буфер не скопировано."
fi
echo "Готово!"
if [[ -n "${OUTFILE:-}" ]]; then
echo "Все файлы собраны в $OUTFILE"
else
echo "OUTFILE пуст — в файл не писали, только буфер (если wl-copy доступен)"
fi

View File

@ -1,39 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUTFILE="all_files.txt"
# === Список файлов (ТОЛЬКО имена без расширений) ===
# пример: Main значит Main.java, Utils значит Utils.java
NAMES=(
"IT_04_UserParams_NoAuth"
"AddBlockSender"
"ChainState"
"JsonBuilders"
)
# очищаем или создаём файл
: > "$OUTFILE"
# Быстрый фильтр: сделаем хеш-таблицу из имён (ассоц. массив)
declare -A WANT=()
for name in "${NAMES[@]}"; do
WANT["$name"]=1
done
# собрать только нужные *.java по базовому имени
find . -type f -name "*.java" | sort | while read -r f; do
base="$(basename "$f" .java)"
if [[ -n "${WANT[$base]+x}" ]]; then
cat "$f" >> "$OUTFILE"
echo >> "$OUTFILE" # пустая строка-разделитель
fi
done
# скопировать весь файл в буфер обмена (Wayland)
wl-copy < "$OUTFILE"
echo "Готово!"
echo "Выбрано имён: ${#NAMES[@]}"
echo "Все нужные .java файлы собраны в $OUTFILE"
echo "Содержимое скопировано в буфер обмена (Wayland)"

File diff suppressed because it is too large Load Diff

View File

@ -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)"

View File

@ -160,9 +160,9 @@ public class IT_01_AddUser {
String blockchainName = JsonParsers.userBlockchainName(resp);
String solanaKey = JsonParsers.userSolanaKey(resp);
String blockchainKey = JsonParsers.userBlockchainKey(resp);
String deviceKey = JsonParsers.userDeviceKey(resp);
String clientKey = JsonParsers.userClientKey(resp);
if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(deviceKey)) {
if (isBlank(login) || isBlank(blockchainName) || isBlank(solanaKey) || isBlank(blockchainKey) || isBlank(clientKey)) {
r.fail("GetUser: exists=true, но поля пустые/неполные, resp=" + resp);
fail("GetUser returned incomplete user data");
}
@ -187,7 +187,7 @@ public class IT_01_AddUser {
// ключи должны совпадать с теми, что AddUser использует при регистрации
String expSol = TestConfig.solanaPublicKeyB64(canonical);
String expBchKey = TestConfig.blockchainPublicKeyB64(canonical);
String expDev = TestConfig.devicePublicKeyB64(canonical);
String expDev = TestConfig.clientPublicKeyB64(canonical);
if (!solanaKey.equals(expSol)) {
r.fail("GetUser: solanaKey mismatch, resp=" + resp);
@ -197,9 +197,9 @@ public class IT_01_AddUser {
r.fail("GetUser: blockchainKey mismatch, resp=" + resp);
fail("GetUser blockchainKey mismatch");
}
if (!deviceKey.equals(expDev)) {
r.fail("GetUser: deviceKey mismatch, resp=" + resp);
fail("GetUser deviceKey mismatch");
if (!clientKey.equals(expDev)) {
r.fail("GetUser: clientKey mismatch, resp=" + resp);
fail("GetUser clientKey mismatch");
}
}
@ -270,7 +270,7 @@ public class IT_01_AddUser {
"blockchainName": "%s",
"solanaKey": "%s",
"blockchainKey": "%s",
"deviceKey": "%s",
"clientKey": "%s",
"bchLimit": %d
}
}

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