Compare commits

..

26 Commits

Author SHA256 Message Date
AidarKC
127c561a41 Слить обновления UI каналов и кошелька 2026-06-24 15:00:02 +04:00
AidarKC
f9a15ab192 Обновить список каналов и кнопку сообщения 2026-06-24 14:59:08 +04:00
AidarKC
77f5759d60 Обновить UI кошелька и регистрацию 2026-06-24 13:48:07 +04:00
AidarKC
684f3237cf Исправить подключение и подпись в браузерном кошельке 2026-06-24 10:28:54 +04:00
AidarKC
23e61cc182 ESP32 регистрация и архив тестового удаления PDA 2026-06-23 20:31:05 +04:00
AidarKC
d2f45ff67a ESP32: приостановить reconnect на экранах регистрации 2026-06-23 19:19:18 +04:00
AidarKC
06e12e9103 ESP32: убрать ранние checkpoint при add homeserver 2026-06-23 19:12:58 +04:00
AidarKC
29dddeff4f ESP32: убрать временный автозапуск homeserver 2026-06-23 19:03:32 +04:00
AidarKC
017d568aea ESP32: автозапуск Add Homeserver для отладки 2026-06-23 18:56:33 +04:00
AidarKC
c91b52cfd2 ESP32: checkpoint перед чтением PDA homeserver 2026-06-23 18:46:41 +04:00
AidarKC
2bd38d8d78 ESP32: диагностический checkpoint для update homeserver 2026-06-23 18:39:58 +04:00
AidarKC
7d9db68d80 ESP32: NTP для update user_pda 2026-06-23 18:32:32 +04:00
AidarKC
4b94303d67 ESP32: server login и NTP для регистрации 2026-06-23 18:11:11 +04:00
AidarKC
08628704c7 UI: добавить просмотр любого PDA 2026-06-23 17:05:57 +04:00
AidarKC
f1c1132690 AuthChallenge: поддержать RecoveryKeyBlock 2026-06-23 17:04:47 +04:00
AidarKC
d2426c473c ESP32: убрать кэш генерации секрета 2026-06-23 14:42:02 +04:00
AidarKC
66986b804c Вынести дефолтный сервер в конфиг UI и оживить счетчик пароля 2026-06-23 14:11:09 +04:00
AidarKC
95daa230bb Обновить серверный UI под recovery key 2026-06-23 13:44:18 +04:00
AidarKC
365b22d778 ESP32: кэш последних генераций секрета 2026-06-23 12:06:24 +04:00
AidarKC
cf2b54464e Исправить update user_pda для homeserver на ESP32 2026-06-23 11:42:44 +04:00
AidarKC
4e60c1274a ESP32: ускорить и упростить secret screen 2026-06-23 11:08:19 +04:00
AidarKC
2f65e63fbe ESP32: новая derivation ключей 2026-06-23 10:51:03 +04:00
AidarKC
b461431197 Смена адреса shine_users и выкладка на test2 2026-06-23 10:40:52 +04:00
AidarKC
5c92b6a734 Миграция PDA на client.key 2026-06-22 21:57:09 +04:00
AidarKC
ba348dafb3 Add Wallet Standard registration for browser wallet 2026-06-22 01:35:26 +04:00
AidarKC
ce2d310e8c ESP32 wallet RPC, browser wallet provider, and side panel 2026-06-22 01:30:08 +04:00
176 changed files with 5304 additions and 31315 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.DatabaseInitializer — разовая сборка схемы (таблицы + индексы).
shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getDeviceKeyByte()). shine.db.entities.* — POJO-модели строк таблиц (без логики, только поля/геттеры/сеттеры + иногда удобные методы вроде getClientKeyByte()).
shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO: shine.db.dao.* — DAO по таблицам: ActiveSessionsDAO, SolanaUsersDAO, UserParamsDAO, IpGeoCacheDAO, BlockchainStateDAO, BlocksDAO; плюс “сервисные” DAO:
UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit). UserCreateDAO — атомарная регистрация пользователя в транзакции (BEGIN IMMEDIATE + rollback/commit).

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@
"param": "display_name", "param": "display_name",
"time_ms": 1774700000123, "time_ms": 1774700000123,
"value": "Alice", "value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY", "client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE" "signature": "BASE64_SIGNATURE"
} }
} }
@ -76,7 +76,7 @@
"param": "display_name", "param": "display_name",
"time_ms": 1774700000123, "time_ms": 1774700000123,
"value": "Alice", "value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY", "client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE" "signature": "BASE64_SIGNATURE"
} }
} }
@ -116,7 +116,7 @@
"param": "display_name", "param": "display_name",
"time_ms": 1774700000123, "time_ms": 1774700000123,
"value": "Alice", "value": "Alice",
"device_key": "BASE64_DEVICE_PUBLIC_KEY", "client_key": "BASE64_DEVICE_PUBLIC_KEY",
"signature": "BASE64_SIGNATURE" "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 (основная работа) ### ESP32 (основная работа)
- [ ] Инициализация WiFi (SSID/пароль в NVS) - [ ] Инициализация WiFi (SSID/пароль в NVS)
- [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером - [ ] WebSocket-клиент (`WebSocketsClient`) — постоянное соединение с сервером
- [ ] Авторизация на сервере: `AuthChallenge``CreateAuthSession` через `deviceKey` (уже есть в NVS), сохранить `sessionId` в NVS - [ ] Авторизация на сервере: `AuthChallenge``CreateAuthSession` через `clientKey` (уже есть в NVS), сохранить `sessionId` в NVS
- [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу - [ ] Обработчик входящих WebSocket-событий: JSON-парсинг, диспетчер по типу
- [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет - [ ] Новые UI-экраны: «Разрешить сессию?» и «Подписать?» с кнопками Да/Нет
- [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх) - [ ] Расширенное хранилище ключей в NVS (произвольные именованные ключи сверх базовых трёх)

View File

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

View File

@ -8,9 +8,9 @@
В SHiNE у пользователя есть несколько уровней ключей: В SHiNE у пользователя есть несколько уровней ключей:
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `device key`). - `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя. - `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
- `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей. - `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере. - `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` — это **главный (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` ## `blockchain key`
@ -50,9 +50,9 @@
Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей. Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей.
## `device key` ## `client key`
`device key` - общий ключ, который знают доверенные устройства пользователя. `client key` - общий ключ, который знают доверенные устройства пользователя.
Назначение: Назначение:
@ -63,11 +63,11 @@
- derivation Arweave-кошелька; - derivation Arweave-кошелька;
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу. - оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
Arweave-кошелёк должен выводиться из `device key` по протоколу: Arweave-кошелёк должен выводиться из `client key` по протоколу:
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md`
Если пользователь теряет только `device key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными. Если пользователь теряет только `client key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными.
## `session key` ## `session key`
@ -83,7 +83,7 @@ Arweave-кошелёк должен выводиться из `device key` по
- авторизация сессии на сервере; - авторизация сессии на сервере;
- привязка устройства к пользователю; - привязка устройства к пользователю;
- подтверждение запросов от конкретной сессии; - подтверждение запросов от конкретной сессии;
- доступ к зашифрованному `device key` после успешной авторизации. - доступ к зашифрованному `client key` после успешной авторизации.
Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает. Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает.
@ -108,14 +108,14 @@ Arweave-кошелёк должен выводиться из `device key` по
Обычное устройство обычно имеет: Обычное устройство обычно имеет:
- собственный `session key`; - собственный `session key`;
- зашифрованный `device key`, который открывается после авторизации; - зашифрованный `client key`, который открывается после авторизации;
- доступ к DM, звонкам и обычным пользовательским операциям. - доступ к DM, звонкам и обычным пользовательским операциям.
Доверенное серверное или аппаратное устройство может иметь: Доверенное серверное или аппаратное устройство может иметь:
- `root key`; - `root key`;
- `blockchain key`; - `blockchain key`;
- `device key`; - `client key`;
- собственный `session key`. - собственный `session key`.
Такая сессия может подписывать операции повышенной важности по запросам пользователя. Такая сессия может подписывать операции повышенной важности по запросам пользователя.
@ -139,7 +139,7 @@ Self-message - это сообщение пользователя самому
Входящее сообщение может быть зашифровано: Входящее сообщение может быть зашифровано:
- `device key`; - `client key`;
- `session 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/Personal_Messages/README.md` - текущая документация личных сообщений.
- `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна. - `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
- `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств. - `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств.
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации. - `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`; - какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`;
- формат self-message-команд; - формат self-message-команд;
- порядок перебора ключей при расшифровке входящих сообщений; - порядок перебора ключей при расшифровке входящих сообщений;
- правила ротации `device key` и восстановления доступа после потери устройства; - правила ротации `client key` и восстановления доступа после потери устройства;
- какие типы серверных и аппаратных сессий нужны в первой реализации. - какие типы серверных и аппаратных сессий нужны в первой реализации.

View File

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

View File

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

View File

@ -0,0 +1,18 @@
# ESP32 English UI and trusted login fix
- Краткое описание:
переведён экранный UI ESP32 homeserver на английский язык; добавлена локальная инструкция для AGENTS по ограничению кириллицы; исправлено падение `StartTrustedDeviceLogin`, когда у пользователя ещё нет записи `esp_pairing_settings`.
- Что проверять:
1. На ESP32 открыть `HOME`, `WALLET`, `SELECT WALLET`, `WALLET QR` и убедиться, что пользовательские строки на экране только на английском.
2. Проверить сценарий выбора `ClientKey`, `RootKey`, `Custom` и чтение баланса/QR после переключения.
3. В расширении открыть popup, запустить `Подключить` для логина, у которого ещё не настраивался trusted-device login.
4. Убедиться, что `StartTrustedDeviceLogin` больше не падает с `NullPointerException`.
5. После создания pairing-запроса проверить, что homeserver/UI может его увидеть и обработать как раньше.
- Ожидаемый результат:
- ESP32 UI читается на устройстве без кириллических строк.
- Подключение через trusted-device login стартует без server-side `INTERNAL_HANDLER_ERROR`, даже если настройки pairing ещё ни разу не сохранялись.
- Статус:
`pending`

View File

@ -0,0 +1,20 @@
# Browser wallet side panel
- Краткое описание:
browser extension `SHiNE Wallet` переведён с toolbar popup на штатный Chromium side panel.
- Что проверять:
1. Перезагрузить unpacked extension в Chromium-браузере.
2. Нажать на иконку `SHiNE Wallet` в toolbar.
3. Убедиться, что открывается side panel, а не всплывающий popup.
4. Проверить, что панель можно держать открытой при навигации по сайтам.
5. Проверить сценарии `Подключить`, `Отключить`, `Обновить устройства`, `Запросить кошелёк`.
6. Проверить, что сторона панели управляется браузером, а UI корректно выглядит и слева, и справа.
- Ожидаемый результат:
- расширение открывается в штатной боковой панели Chromium;
- UI работает так же, как раньше в popup;
- панель остаётся доступной как постоянная колонка браузера.
- Статус:
`pending`

View File

@ -0,0 +1,23 @@
# Wallet provider and ESP sign_transaction
- Краткое описание:
browser extension `SHiNE Wallet` теперь внедряет `window.solana` для сайтов и умеет выполнять `connect` и `signTransaction`; подпись транзакции уходит на ESP32 через wallet RPC `sign_transaction`, а подтверждение делается на устройстве.
- Что проверять:
1. Перезагрузить unpacked extension в Chromium.
2. Открыть сайт/тестовую страницу, которая вызывает `window.solana.connect()`.
3. Подтвердить подключение кошелька и убедиться, что сайт получает публичный ключ.
4. Открыть сайт/тестовую страницу, которая вызывает `window.solana.signTransaction(...)`.
5. Убедиться, что на ESP32 открывается экран `SIGN REQUEST` с комментарием.
6. Проверить оба варианта:
- `APPROVE` возвращает сайту подписанную транзакцию;
- `REJECT` возвращает отказ.
7. Проверить сценарии для `ClientKey`, `RootKey`, `Custom`.
- Ожидаемый результат:
- сайт может подключить кошелёк через provider расширения;
- транзакция подписывается только после подтверждения на ESP32;
- отказ на ESP32 корректно доходит до сайта.
- Статус:
`pending`

View File

@ -0,0 +1,18 @@
# Wallet Standard support
- Краткое описание:
расширение `SHiNE Wallet` теперь не только внедряет legacy `window.solana`, но и регистрирует себя как `Wallet Standard` wallet для Solana dapp.
- Что проверять:
1. Перезагрузить unpacked extension.
2. Открыть dapp, который использует Wallet Standard, например `app.realms.today` на `devnet`.
3. Открыть список кошельков и убедиться, что `SHiNE Wallet` появился отдельным вариантом.
4. Проверить `connect`.
5. Проверить подпись транзакции через сценарий dapp, который использует стандартный wallet interface.
- Ожидаемый результат:
- dapp видит `SHiNE Wallet` как standard wallet, а не только как legacy Phantom-style provider.
- connect и подпись работают через тот же ESP32 approval-flow.
- Статус:
`pending`

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; - данные одного или нескольких пользовательских блокчейнов SHiNE;
- серверные данные пользователя, если пользователь выступает сервером; - серверные данные пользователя, если пользователь выступает сервером;
- серверы доступа пользователя; - серверы доступа пользователя;
@ -34,9 +35,9 @@
## 2.1. Кто оплачивает create/update PDA ## 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. - `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer.
- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `device_key`. - Для server PDA это правило то же самое: пополнять SOL нужно на адрес `client_key`.
## 3. Общие правила кодирования ## 3. Общие правила кодирования
@ -85,8 +86,9 @@ UserPdaRecordV1
| block_type | Блок | Назначение | | block_type | Блок | Назначение |
|------------|------|------------| |------------|------|------------|
| `0` | `RecoveryKeyBlock` | Ключ восстановления пользователя. |
| `1` | `RootKeyBlock` | Корневой ключ пользователя. | | `1` | `RootKeyBlock` | Корневой ключ пользователя. |
| `2` | `DeviceKeyBlock` | Ключ устройства пользователя. | | `2` | `ClientKeyBlock` | Клиентский ключ пользователя. |
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | | `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. | | `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. | | `40` | `AccessServersBlock` | Серверы доступа/relay. |
@ -97,13 +99,31 @@ UserPdaRecordV1
Правила: Правила:
- неизвестный `block_type` в `format_major = 1` считается ошибкой; - неизвестный `block_type` в `format_major = 1` считается ошибкой;
- обязательные блоки: `RootKeyBlock`, `DeviceKeyBlock`, `BlockchainRegistryBlock`; - обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`;
- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; - необязательные блоки: `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`. Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`.
@ -120,24 +140,24 @@ RootKeyBlock
- при обновлении `root_key` должен совпадать с предыдущей записью; - при обновлении `root_key` должен совпадать с предыдущей записью;
- ротация root-key будет отдельным форматом/сценарием в будущем. - ротация root-key будет отдельным форматом/сценарием в будущем.
## 7. DeviceKeyBlock ## 8. ClientKeyBlock
Смена `device_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один ключ устройства. Смена `client_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один клиентский ключ пользователя.
```text ```text
DeviceKeyBlock ClientKeyBlock
- block_type: u8 = 2 - block_type: u8 = 2
- block_version: u8 = 0 - block_version: u8 = 0
- device_key: [u8; 32] - client_key: [u8; 32]
``` ```
Правила: Правила:
- при создании задается текущий публичный ключ устройства; - при создании задается текущий клиентский публичный ключ пользователя;
- при обновлении ключ устройства может быть обновлен только если это отдельно разрешено бизнес-логикой инструкции; - при обновлении `client_key` должен совпадать с предыдущей записью;
- история устройств и несколько устройств в этом формате не хранятся. - история устройств и несколько клиентских ключей в этом формате не хранятся.
## 8. BlockchainRegistryBlock ## 9. BlockchainRegistryBlock
Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список. Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список.
@ -155,7 +175,7 @@ BlockchainRegistryBlock
- в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`; - в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`;
- каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн. - каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн.
## 9. BlockchainRecord ## 10. BlockchainRecord
```text ```text
BlockchainRecord BlockchainRecord
@ -191,7 +211,7 @@ BlockchainRecord
Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя. 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` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера. Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера.
## 11. ServerProfileBlock ## 12. ServerProfileBlock
Блок присутствует, если пользователь выступает сервером. Блок присутствует, если пользователь выступает сервером.
@ -255,7 +275,7 @@ ServerProfileBlock
- `server_address` - строковый адрес сервера в соответствии с `address_format_type`; - `server_address` - строковый адрес сервера в соответствии с `address_format_type`;
- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. - `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует блокчейн и личные сообщения. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы.
## 12. AccessServersBlock ## 13. AccessServersBlock
Блок хранит серверы доступа/relay для пользователя. Блок хранит серверы доступа/relay для пользователя.
@ -274,7 +294,7 @@ AccessServersBlock
- `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы; - `access_servers` - логины пользователей системы, используемых как серверы доступа/relay. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы;
- точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE. - точная семантика выбора сервера доступа определяется клиентской/серверной логикой SHiNE.
## 13. SessionsBlock ## 14. SessionsBlock
Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком. Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком.
@ -309,6 +329,7 @@ SessionRecord
| Значение | Смысл | | Значение | Смысл |
|----------|-------| |----------|-------|
| `1` | Обычная пользовательская сессия. | | `1` | Обычная пользовательская сессия. |
| `50` | Кошелёк пользователя. |
| `100` | Homeserver пользователя. | | `100` | Homeserver пользователя. |
Правила: Правила:
@ -320,7 +341,7 @@ SessionRecord
- внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`; - внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`;
- на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически. - на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически.
## 14. TrustedStateBlock ## 15. TrustedStateBlock
Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик. Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик.
@ -333,7 +354,7 @@ TrustedStateBlock
Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат.
## 15. Подпись user_pda ## 16. Подпись user_pda
Подписывается не вся PDA целиком, а unsigned-часть записи: Подписывается не вся PDA целиком, а unsigned-часть записи:
@ -354,7 +375,7 @@ Solana-программа проверяет подпись через встр
Смену формата подписи сейчас не трогаем. Смену формата подписи сейчас не трогаем.
## 16. Регистрация пользователя ## 17. Регистрация пользователя
При регистрации: При регистрации:
@ -372,12 +393,12 @@ Solana-программа проверяет подпись через встр
- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит; - если покупается дополнительный лимит, пользователь платит комиссию за этот лимит;
- вся unsigned-часть записи подписана `root_key`. - вся unsigned-часть записи подписана `root_key`.
## 17. Обновление пользователя ## 18. Обновление пользователя
При обновлении: При обновлении:
- PDA должна существовать; - PDA должна существовать;
- `login`, `created_at_ms`, `root_key` не меняются; - `login`, `created_at_ms`, `recovery_key`, `root_key`, `client_key` не меняются;
- `record_number = previous_record_number + 1`; - `record_number = previous_record_number + 1`;
- `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `prev_record_hash` равен хэшу unsigned-части предыдущей записи;
- `updated_at_ms` обновляется; - `updated_at_ms` обновляется;
@ -387,7 +408,7 @@ Solana-программа проверяет подпись через встр
- при увеличении оплаченного лимита пользователь доплачивает комиссию; - при увеличении оплаченного лимита пользователь доплачивает комиссию;
- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует. - Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует.
## 18. Отличия от старого линейного формата ## 19. Отличия от старого линейного формата
Старый формат после `login` хранил поля линейно: Старый формат после `login` хранил поля линейно:
@ -395,8 +416,8 @@ Solana-программа проверяет подпись через встр
- `root_key`; - `root_key`;
- `blockchain_key_status`; - `blockchain_key_status`;
- `blockchain_key`; - `blockchain_key`;
- `device_key_status`; - `client_key_status`;
- `device_key`; - `client_key`;
- `chain_number`; - `chain_number`;
- `balance`; - `balance`;
- серверные поля; - серверные поля;
@ -407,17 +428,54 @@ Solana-программа проверяет подпись через встр
Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки: Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки:
- recovery-ключ становится отдельным обязательным блоком;
- ключи становятся отдельными блоками; - ключи становятся отдельными блоками;
- данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`; - данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`;
- серверные данные и access-серверы отделяются от данных блокчейна; - серверные данные и access-серверы отделяются от данных блокчейна;
- расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи. - расширение формата делается добавлением новых версий блоков или новых `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`; - ротацию `root_key`;
- сложную ротацию `device_key`; - сложную ротацию `client_key`;
- ротацию `blockchain_public_key`; - ротацию `blockchain_public_key`;
- проверку содержимого Arweave transaction; - проверку содержимого Arweave transaction;
- хранение полной истории пользовательского блокчейна внутри Solana; - хранение полной истории пользовательского блокчейна внутри Solana;

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@
<rect class="box actor" x="52" y="150" width="210" height="78"/> <rect class="box actor" x="52" y="150" width="210" height="78"/>
<text class="txt" x="72" y="181">Пользователь</text> <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"/> <rect class="box actor" x="52" y="310" width="210" height="78"/>
<text class="txt" x="72" y="341">Покупатель тикета</text> <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`, не из ввода | | `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` | | `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 сверен дважды | | `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 == device_key` | user_pda `owner == program_id`; econ_config `owner == program_id` | деривация + сверка | ✓ | ed25519 + `version == old+1` + `prev_hash == hash(old)` | immutable-поля сверены с прежней записью | | `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 ### shine_payments
@ -111,7 +111,7 @@ commit-reveal; для текущей модели — приемлемый ри
- **Подмена PDA** невозможна нигде: всюду пара «деривация `find_program_address` + сверка полного адреса». Пользовательский bump не принимается, `create_program_address` с внешним bump не используется — bump-seed атаки исключены. - **Подмена 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: `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` исключает повторную инициализацию и перезапись чужого аккаунта. - **Создаваемые 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-интроспекцией. - **system-program** сверяется с `system_program::ID` там, где идёт создание аккаунта/перевод; **sysvar инструкций** сверяется с `sysvar::instructions::id()` перед ed25519-интроспекцией.
- **Аккаунт оракула**: пин адреса `PYTH_SOL_USD_ACCOUNT` + `owner == pyth_receiver` + `feed_id` + возраст (120 с) + доверительный интервал (10%). - **Аккаунт оракула**: пин адреса `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 по хэшу — указать на чужую инструкцию нельзя. - **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) ## Актуальные адреса (devnet)
- `shine_users` (регистрация пользователей): - `shine_users` (регистрация пользователей):
`FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- `shine_login_guard`: - `shine_login_guard`:
`3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
- `shine_payments`: - `shine_payments`:
@ -22,7 +22,7 @@
- Сеть: `https://api.devnet.solana.com` - Сеть: `https://api.devnet.solana.com`
- `shine_users`: - `shine_users`:
- `Program ID`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` - `Program ID`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM` - TX deploy: `5VzfpSirFCRqPUZfvAt3eADY9KnowW79PKZ1pCQAa2DJGiztj4dUYYXrSQNmWEhPVu6mPSDfcuHzFyEVmoKLa9DM`
- `shine_login_guard`: - `shine_login_guard`:
- `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo` - `Program ID`: `3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo`
@ -64,11 +64,38 @@ anchor deploy -p shine_users
- Переход на страницу: - Переход на страницу:
- `shine-UI/js/pages/developer-settings-view.js` - `shine-UI/js/pages/developer-settings-view.js`
### Browser plugin wallet
- Резолвер PDA и проверка адресов:
- `SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js`
- Проверка текущего wallet по PDA:
- `SHiNE-browser-plugin-wallet/background.js`
- Отображение состояния в popup:
- `SHiNE-browser-plugin-wallet/popup.js`
### Сервер ### Сервер
- Серверные константы Solana: - Серверные константы Solana:
- `shine-server-config/src/main/java/utils/config/SolanaProgramsConfig.java` - `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 ## Как запустить инициализацию economy PDA
1. Открыть UI. 1. Открыть UI.
@ -80,19 +107,19 @@ anchor deploy -p shine_users
Страница сама вычисляет PDA `users_economy_config` по seed: Страница сама вычисляет PDA `users_economy_config` по seed:
- seed: `shine_users_economy_config` - seed: `shine_users_economy_config`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` - program: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
## Кто оплачивает create/update user_pda ## Кто оплачивает create/update user_pda
- И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `deviceKey`. - И обычная регистрация `create_user_pda`, и последующее `update_user_pda` оплачиваются с `clientKey`.
- В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера. - В UI это означает, что Solana fee payer всегда берётся из `device`-ключа пользователя/сервера.
- `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию. - `rootKey` нужен для подписи unsigned PDA-записи, но не оплачивает транзакцию.
- Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `deviceKey`. - Для server UI это особенно важно: перед `create` и `update` нужно пополнять именно Solana-адрес `clientKey`.
## Важно ## Важно
- `init_users_economy_config` выполняется один раз на программу. - `init_users_economy_config` выполняется один раз на программу.
Если PDA уже создан, повторный вызов вернёт ошибку "already initialized" (это нормальное поведение). Если 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-классификации логина. - `shine_users` внутри `create_user_pda` требует корректный адрес `shine_login_guard` для CPI-классификации логина.
Несовпадение адреса приведёт к ошибке регистрации. Несовпадение адреса приведёт к ошибке регистрации.

View File

@ -4,7 +4,7 @@
## 1. Текущие режимы ## 1. Текущие режимы
### 1. Создание новой сессии через `deviceKey` ### 1. Создание новой сессии через `clientKey`
Поток: Поток:
@ -12,8 +12,8 @@
Смысл: Смысл:
- новое устройство уже владеет приватным `deviceKey`; - новое устройство уже владеет приватным `clientKey`;
- сервер проверяет подпись `deviceKey`; - сервер проверяет подпись `clientKey`;
- создаётся обычная активная сессия пользователя; - создаётся обычная активная сессия пользователя;
- этот поток остаётся без изменений. - этот поток остаётся без изменений.
@ -67,7 +67,7 @@
## 4. Чего сервер в этой версии не делает ## 4. Чего сервер в этой версии не делает
- не передаёт приватный `deviceKey`; - не передаёт приватный `clientKey`;
- не расшифровывает `encryptedPayload`; - не расшифровывает `encryptedPayload`;
- не проверяет криптографию содержимого payload; - не проверяет криптографию содержимого payload;
- не делает клиентский UI; - не делает клиентский UI;
@ -110,3 +110,9 @@
```text ```text
sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )> sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
``` ```
## 8. Связанный документ по внешнему кошельку
Для отдельного RPC-взаимодействия между браузерным wallet-расширением и ESP32 см. документ:
- [Формат_взаимодействия_внешнегоошелька_и_ESP32.md](/home/ai/work/SHiNE/SHiNE-server-sha256/Dev_Docs/Протоколы/Формат_взаимодействия_внешнегоошелька_и_ESP32.md)

View File

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

View File

@ -0,0 +1,311 @@
# Формат взаимодействия внешнего кошелька и ESP32
Этот документ фиксирует первый этап формата взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`.
Документ описывает:
- как расширение получает текущий активный публичный ключ кошелька с ESP32;
- как расширение отправляет на ESP32 запрос подписи транзакции;
- что именно считается активным кошельком на ESP32;
- какие проверки и UI-реакции ожидаются в браузерном расширении и на устройстве;
- какие ограничения действуют в текущей версии протокола.
## 1. Общая идея
Устройство ESP32 хранит `master secret` пользователя и локально умеет выводить несколько кошельков из одного секрета.
На устройстве в UI пользователь выбирает текущий активный кошелёк:
- `client.key`
- `root.key`
- `custom`
Для `custom` используется derivation:
```text
sha256(base64(secret32) + "|wallet." + customName)
```
Браузерное расширение не указывает ESP32, какой кошелёк нужно вернуть в первом запросе. Оно просто спрашивает:
```text
какой кошелёк сейчас активен на устройстве
```
ESP32 возвращает:
- тип текущего активного кошелька;
- его публичный ключ `Base58`.
## 2. Транспорт и маршрут
Первая версия формата использует уже существующую `wallet-session` браузерного расширения.
Схема маршрута:
`browser extension -> SHiNE server -> homeserver session on ESP32 -> SHiNE server -> browser extension`
В первой версии:
- отдельная цифровая подпись payload не добавляется;
- отдельное E2E-шифрование для wallet RPC не добавляется;
- используется существующая авторизованная `wallet-session`, транспорт `WSS` и server-side маршрут через уже существующую операцию `CallSignalToSession`.
## 3. Запрос текущего публичного ключа кошелька
### 3.1. Назначение
Операция нужна, чтобы браузерное расширение могло узнать, какой кошелёк сейчас выбран на ESP32, и показать его пользователю перед дальнейшими действиями.
### 3.2. Формат запроса
```json
{
"v": 1,
"operation": "get_wallet_public_key",
"requestId": "1718998123456-482193",
"timeMs": 1718998123456
}
```
### 3.3. Поля запроса
- `v` — версия формата wallet RPC. Для текущего варианта: `1`.
- `operation` — строка операции. Для текущего запроса: `get_wallet_public_key`.
- `requestId` — идентификатор запроса, уникальный в пределах сеанса расширения. Рекомендуемый формат:
`timeMs-random`.
- `timeMs` — локальное время отправителя в миллисекундах.
### 3.4. Поведение ESP32
При получении такого запроса ESP32:
1. смотрит, какой кошелёк сейчас выбран в локальном UI;
2. вычисляет или берёт уже подготовленный публичный ключ именно этого активного кошелька;
3. возвращает тип кошелька и его `publicKeyBase58`.
Запрос не содержит:
- `walletSelector`;
- `customName`;
- `targetSessionName`.
Они намеренно не входят в первую версию этого запроса.
## 4. Формат ответа
```json
{
"v": 1,
"op": "get_wallet_public_key_result",
"requestId": "1718998123456-482193",
"ok": true,
"wallet": {
"type": "custom",
"publicKeyBase58": "...."
},
"timeMs": 1718998123999
}
```
## 5. Поля ответа
- `v` — версия формата ответа. Сейчас `1`.
- `op` — строка результата операции. Сейчас `get_wallet_public_key_result`.
- `requestId` — должен совпадать с `requestId` исходного запроса.
- `ok` — признак успешного результата.
- `wallet.type` — тип активного кошелька:
- `client.key`
- `root.key`
- `custom`
- `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`.
- `timeMs` — время формирования ответа на стороне ESP32 в миллисекундах.
## 6. Ошибки первой версии
Минимальный формат ошибки в первой версии допускается таким:
```json
{
"v": 1,
"op": "get_wallet_public_key_result",
"requestId": "1718998123456-482193",
"ok": false,
"error": "wallet_unavailable",
"timeMs": 1718998123999
}
```
Рекомендуемые коды ошибок первой версии:
- `wallet_unavailable` — на устройстве нельзя получить текущий кошелёк;
- `secret_not_configured` — на устройстве ещё нет корректно сохранённого секрета;
- `wallet_type_unknown` — выбранный локальный тип кошелька не распознан;
- `internal_error` — прочая локальная ошибка устройства.
## 7. Правила для браузерного расширения
После ответа `ok=true` расширение должно:
1. показать пользователю тип кошелька;
2. показать полный `publicKeyBase58`;
3. дать кнопку копирования ключа в буфер;
4. сохранить этот ключ как текущий ключ устройства для следующей операции подписи.
### 7.1. Проверка через PDA Solana
Расширение уже знает публичные ключи пользователя из Solana PDA. Поэтому оно может дополнительно проверить ответ ESP32:
- если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA;
- если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA;
- если `wallet.type = custom`, такой проверки по PDA в первой версии нет.
При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA.
## 8. Ожидаемое поведение UI расширения
### 8.1. Общий вид popup
Popup браузерного расширения должен быть узким и вытянутым по вертикали.
### 8.2. Состояние без подключения
Если `wallet-session` ещё не подключена:
- показывается кнопка `Подключить`;
- по нажатию открывается экран подключения, близкий по смыслу к сценарию `Войти через другое устройство`;
- пользователь вводит логин устройства и получает код подключения.
### 8.3. Состояние после подключения
Если `wallet-session` уже подключена:
- показывается статус `Подключено`;
- остаётся выбор homeserver;
- появляется кнопка запроса текущего кошелька;
- появляется кнопка `Отключить`.
### 8.4. Подключение кошелька с сайта
Когда сайт просит подключить кошелёк через расширение, расширение должно вести себя как обычный wallet extension:
1. показать пользователю подтверждение подключения;
2. показать, какой именно кошелёк будет подключён;
3. после подтверждения пользователя завершить подключение;
4. если пользователь отказался, не подключать кошелёк к сайту.
## 9. Запрос подписи транзакции
### 9.1. Назначение
Операция нужна, чтобы браузерное расширение могло запросить у ESP32 подпись Solana-транзакции текущим активным кошельком.
Расширение передаёт:
- публичный ключ, которым ожидается подпись;
- сериализованную транзакцию;
- комментарий, который должен быть показан на экране ESP32.
ESP32:
1. показывает пользователю запрос подтверждения;
2. показывает комментарий к подписи;
3. после нажатия `APPROVE` или `REJECT` возвращает ответ в расширение.
### 9.2. Формат запроса
```json
{
"v": 1,
"operation": "sign_transaction",
"requestId": "1718998123456-482193",
"timeMs": 1718998123456,
"publicKeyBase58": "....",
"transactionBase64": "....",
"comment": "Site https://example.com requested transaction signature"
}
```
### 9.3. Поля запроса
- `v` — версия wallet RPC. Сейчас `1`.
- `operation` — строка операции: `sign_transaction`.
- `requestId` — идентификатор запроса.
- `timeMs` — время отправки на стороне расширения.
- `publicKeyBase58` — публичный ключ, от которого ожидается подпись.
- `transactionBase64` — сериализованная Solana transaction в `base64`.
- `comment` — короткое текстовое описание, которое ESP32 показывает пользователю при запросе подписи.
### 9.4. Поведение ESP32
При получении такого запроса ESP32:
1. сравнивает `publicKeyBase58` с публичным ключом текущего активного выбранного кошелька;
2. если ключ не совпадает, сразу возвращает ошибку `wallet_mismatch`;
3. если ключ совпадает, показывает отдельный экран подтверждения подписи;
4. на экране показывает:
- каким кошельком будет выполнена подпись;
- комментарий `comment`;
- кнопки `APPROVE` и `REJECT`;
5. если пользователь подтверждает подпись, ESP32 подписывает транзакцию и возвращает результат;
6. если пользователь отклоняет, ESP32 возвращает `rejected_by_user`.
## 10. Формат ответа на подпись
```json
{
"v": 1,
"op": "sign_transaction_result",
"requestId": "1718998123456-482193",
"ok": true,
"publicKeyBase58": "....",
"signatureBase58": "....",
"signedTransactionBase64": "....",
"timeMs": 1718998123999
}
```
Если пользователь отклонил запрос:
```json
{
"v": 1,
"op": "sign_transaction_result",
"requestId": "1718998123456-482193",
"ok": false,
"error": "rejected_by_user",
"timeMs": 1718998123999
}
```
Рекомендуемые ошибки для `sign_transaction`:
- `rejected_by_user`
- `wallet_unavailable`
- `wallet_mismatch`
- `transaction_base64_invalid`
- `transaction_sign_failed`
- `bad_request`
## 11. Подключение кошелька с сайта
При вызове сайта `connect wallet` расширение должно вести себя как обычный wallet extension:
1. запросить подтверждение у пользователя в браузере;
2. получить текущий публичный ключ с ESP32;
3. вернуть сайту `publicKey` текущего активного кошелька.
Для `signTransaction` расширение:
1. получает транзакцию от сайта;
2. пересылает её на ESP32 через `sign_transaction`;
3. ждёт решение пользователя на устройстве;
4. возвращает браузеру уже подписанную транзакцию.
## 12. Ограничения текущей версии
- запрос возвращает только текущий активный кошелёк, а не список всех кошельков;
- выбор типа кошелька делается только на самом ESP32;
- отдельная цифровая подпись ответа пока не используется;
- отдельное E2E-шифрование wallet RPC пока не используется;
- `custom`-кошельки в первой версии не сверяются с PDA.

28
ESP32/AGENTS.md Normal file
View File

@ -0,0 +1,28 @@
# AGENTS for ESP32
## Язык UI
- Для ESP32-скетчей и экранного UI использовать английский язык.
- Русский текст на экране ESP32 пока не поддерживается корректно: шрифтовой путь для кириллицы не считается рабочим.
- Если меняется UI-скетч, все пользовательские строки на экране должны оставаться английскими, пока ограничение не снято отдельной задачей.
## Синхронизация со спецификацией
- При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/`.
## Сборка ESP32
- Основной способ проверки и прошивки скетчей для `ESP32-S3-Touch-AMOLED-2.16` - `main-device/burn.sh`.
- Не собирать эти скетчи напрямую через `arduino-cli compile` без `burn.sh`, потому что скрипт добавляет нужные локальные библиотеки и конфиги из `official-demo/examples/Arduino-v3.3.5/libraries`.
- Если сборка падает по `lv_conf.h` или `TouchDrvCSTXXX.hpp`, сначала проверять именно `burn.sh` и его `--library` пути, а не считать, что файл пропал из репозитория.
## Диагностика ESP32
- Последнюю сохранённую ошибку или диагностическую запись читать с устройства через USB serial monitor на `115200`.
- Базовая команда:
`arduino-cli monitor -p /dev/ttyACM0 --config baudrate=115200`
- После подключения отправлять одну из команд:
`last_error`, `last_diag` или `reg_diag`
- Для очистки сохранённой диагностики использовать:
`clear_error` или `clear_diag`
- При падениях в ветках регистрации и обновления PDA сначала читать именно `last_error`: запись хранится в NVS и может пережить перезагрузку устройства.

View File

@ -11,7 +11,7 @@
* legacy(empty password): * legacy(empty password):
* secret = SHA256(base64(SHA256(password)) + "master.secret") * secret = SHA256(base64(SHA256(password)) + "master.secret")
* keyPair_i = Ed25519(SHA256(base64(secret) + "|" + suffix_i)) * 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 * Плата: Waveshare ESP32-S3-Touch-AMOLED-2.16
* SD : SDMMC 1-bit CLK=GPIO2, CMD=GPIO1, D0=GPIO3 * 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]; }; struct KeyPair { uint8_t pub[32]; uint8_t priv[32]; };
static KeyPair gKeys[3]; static KeyPair gKeys[3];
static const char * KEY_SUFFIXES[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", "bch.key", "dev.key"}; static const char * KEY_LABELS[3] = {"root.key", "blockchain.key", "client.key"};
static uint32_t gElapsedSec = 0; static uint32_t gElapsedSec = 0;
// Base58 представления (43-44 символа для 32 байт + \0) // Base58 представления (43-44 символа для 32 байт + \0)

View File

@ -1,7 +1,6 @@
#include "shine_secret_generation.h" #include "shine_secret_generation.h"
#include <SD_MMC.h> #include <SD_MMC.h>
#include <Preferences.h>
#include <mbedtls/sha256.h> #include <mbedtls/sha256.h>
#include <mbedtls/base64.h> #include <mbedtls/base64.h>
#include <string.h> #include <string.h>
@ -80,6 +79,19 @@ static void setMessage(const char *message) {
snprintf(gMessage, sizeof(gMessage), "%s", message ? 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) { static void b2_compress(B2State *S, const uint8_t *blk) {
uint64_t m[16], v[16]; uint64_t m[16], v[16];
for (int i = 0; i < 16; i++) m[i] = ((const uint64_t *)blk)[i]; 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) { bool shineSecretStart(const char *normalizedLogin, const char *password, String &error) {
error = ""; error = "";
if (!shineSecretInitSd(error)) return false;
if (!normalizedLogin || !*normalizedLogin) { if (!normalizedLogin || !*normalizedLogin) {
error = "login not set"; error = "login not set";
return false; return false;
@ -463,6 +474,8 @@ bool shineSecretStart(const char *normalizedLogin, const char *password, String
return false; return false;
} }
if (!shineSecretInitSd(error)) return false;
if (gSdFile) gSdFile.close(); if (gSdFile) gSdFile.close();
SD_MMC.remove(SD_MEM_FILE); SD_MMC.remove(SD_MEM_FILE);
gSdFile = SD_MMC.open(SD_MEM_FILE, "w+"); gSdFile = SD_MMC.open(SD_MEM_FILE, "w+");

View File

@ -224,12 +224,14 @@ static int16_t gTouchLastY = 0;
struct DerivedKeyState { struct DerivedKeyState {
bool ready; bool ready;
uint8_t masterSecret[32]; uint8_t masterSecret[32];
uint8_t recoveryPub[32];
uint8_t recoverySk[64];
uint8_t rootPub[32]; uint8_t rootPub[32];
uint8_t rootSk[64]; uint8_t rootSk[64];
uint8_t blockchainPub[32]; uint8_t blockchainPub[32];
uint8_t blockchainSk[64]; uint8_t blockchainSk[64];
uint8_t devicePub[32]; uint8_t clientPub[32];
uint8_t deviceSk[64]; uint8_t clientSk[64];
}; };
static DerivedKeyState gDerivedKeys = {}; static DerivedKeyState gDerivedKeys = {};
@ -237,7 +239,7 @@ static DerivedKeyState gDerivedKeys = {};
static const char *kSystemProgramId = "11111111111111111111111111111111"; static const char *kSystemProgramId = "11111111111111111111111111111111";
static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111"; static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111";
static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111"; static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111";
static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; static const char *kShineUsersProgramId = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW";
static const char *kUsersSeedPrefix = "user_login="; 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 *kProgramDerivedAddressMarker = "ProgramDerivedAddress";
static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK"; static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK";
static const uint8_t kBlockTypeRootKey = 1; 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 kBlockTypeBlockchainRegistry = 3;
static const uint8_t kBlockTypeServerProfile = 30; static const uint8_t kBlockTypeServerProfile = 30;
static const uint8_t kBlockTypeAccessServers = 40; 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]) { static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) {
memset(&gDerivedKeys, 0, sizeof(gDerivedKeys)); memset(&gDerivedKeys, 0, sizeof(gDerivedKeys));
memcpy(gDerivedKeys.masterSecret, masterSecret, 32); memcpy(gDerivedKeys.masterSecret, masterSecret, 32);
String secretB64 = base64Encode(masterSecret, 32); const char *prefix = "SHiNE-key";
if (secretB64.length() == 0) { const char *suffixes[4] = {"recovery.key", "root.key", "blockchain.key", "client.key"};
return false; uint8_t *pubs[4] = {gDerivedKeys.recoveryPub, gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.clientPub};
} uint8_t *sks[4] = {gDerivedKeys.recoverySk, gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.clientSk};
const char *suffixes[3] = {"root.key", "bch.key", "dev.key"}; for (int i = 0; i < 4; i++) {
uint8_t *pubs[3] = {gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.devicePub}; std::vector<uint8_t> material;
uint8_t *sks[3] = {gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.deviceSk}; material.reserve(strlen(prefix) + 1 + 32 + 1 + strlen(suffixes[i]));
for (int i = 0; i < 3; i++) { material.insert(material.end(), prefix, prefix + strlen(prefix));
String material = secretB64 + "|" + suffixes[i]; 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]; uint8_t seed[32];
if (!sha256String(material, seed)) { sha256Raw(material.data(), material.size(), seed);
return false;
}
if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) { if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) {
return false; return false;
} }
@ -822,7 +825,7 @@ static bool restoreDerivedKeysFromSecret() {
return false; return false;
} }
gData.secretReady = true; gData.secretReady = true;
gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32); gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32);
return true; return true;
} }
@ -835,7 +838,7 @@ static bool deriveFreshSecretAndWallet() {
return false; return false;
} }
gData.secret = bytesToBase58(secret, sizeof(secret)); gData.secret = bytesToBase58(secret, sizeof(secret));
gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32); gData.walletAddress = bytesToBase58(gDerivedKeys.clientPub, 32);
gData.secretReady = true; gData.secretReady = true;
return true; return true;
} }
@ -889,7 +892,7 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
const String &blockchainName, const String &blockchainName,
const String &serverAddress, const String &serverAddress,
const uint8_t rootPub[32], const uint8_t rootPub[32],
const uint8_t devicePub[32], const uint8_t clientPub[32],
const uint8_t blockchainPub[32], const uint8_t blockchainPub[32],
const uint8_t lastBlockSignature[64], const uint8_t lastBlockSignature[64],
uint64_t createdAtMs) { uint64_t createdAtMs) {
@ -911,9 +914,9 @@ static std::vector<uint8_t> buildUnsignedCreateRecord(
out.push_back(0); out.push_back(0);
pushFixed(out, rootPub, 32); pushFixed(out, rootPub, 32);
out.push_back(kBlockTypeDeviceKey); out.push_back(kBlockTypeClientKey);
out.push_back(0); out.push_back(0);
pushFixed(out, devicePub, 32); pushFixed(out, clientPub, 32);
out.push_back(kBlockTypeBlockchainRegistry); out.push_back(kBlockTypeBlockchainRegistry);
out.push_back(0); out.push_back(0);
@ -960,7 +963,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
const String &blockchainName, const String &blockchainName,
const String &serverAddress, const String &serverAddress,
const uint8_t rootPub[32], const uint8_t rootPub[32],
const uint8_t devicePub[32], const uint8_t clientPub[32],
const uint8_t blockchainPub[32], const uint8_t blockchainPub[32],
const uint8_t lastBlockSignature[64], const uint8_t lastBlockSignature[64],
const uint8_t rootSignature[64], const uint8_t rootSignature[64],
@ -972,7 +975,7 @@ static std::vector<uint8_t> buildCreateInstructionData(
pushFixed(out, rootPub, 32); pushFixed(out, rootPub, 32);
pushU64LE(out, createdAtMs); pushU64LE(out, createdAtMs);
pushU64LE(out, 0); pushU64LE(out, 0);
pushFixed(out, devicePub, 32); pushFixed(out, clientPub, 32);
pushFixed(out, blockchainPub, 32); pushFixed(out, blockchainPub, 32);
pushStrU8(out, blockchainName); pushStrU8(out, blockchainName);
pushU64LE(out, 0); pushU64LE(out, 0);
@ -1079,7 +1082,7 @@ static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &me
static std::vector<uint8_t> buildLegacyMessage( static std::vector<uint8_t> buildLegacyMessage(
const uint8_t recentBlockhash[32], const uint8_t recentBlockhash[32],
const uint8_t devicePub[32], const uint8_t clientPub[32],
const uint8_t userPda[32], const uint8_t userPda[32],
const uint8_t inflowVault[32], const uint8_t inflowVault[32],
const uint8_t economyConfig[32], const uint8_t economyConfig[32],
@ -1098,7 +1101,7 @@ static std::vector<uint8_t> buildLegacyMessage(
base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram); base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram);
std::vector<std::vector<uint8_t>> accountKeys; 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(userPda, userPda + 32);
accountKeys.emplace_back(inflowVault, inflowVault + 32); accountKeys.emplace_back(inflowVault, inflowVault + 32);
accountKeys.emplace_back(systemProgram, systemProgram + 32); accountKeys.emplace_back(systemProgram, systemProgram + 32);
@ -1244,7 +1247,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
uint64_t createdAtMs = (uint64_t)millis() + 1704067200000ULL; uint64_t createdAtMs = (uint64_t)millis() + 1704067200000ULL;
std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord( std::vector<uint8_t> unsignedRecord = buildUnsignedCreateRecord(
gData.login, blockchainName, gData.wsUrl, gData.login, blockchainName, gData.wsUrl,
gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub, gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub,
lastBlockSignature, createdAtMs); lastBlockSignature, createdAtMs);
uint8_t unsignedHash[32]; uint8_t unsignedHash[32];
uint8_t rootSignature[64]; uint8_t rootSignature[64];
@ -1256,7 +1259,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
std::vector<uint8_t> createData = buildCreateInstructionData( std::vector<uint8_t> createData = buildCreateInstructionData(
gData.login, blockchainName, gData.wsUrl, gData.login, blockchainName, gData.wsUrl,
gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub, gDerivedKeys.rootPub, gDerivedKeys.clientPub, gDerivedKeys.blockchainPub,
lastBlockSignature, rootSignature, createdAtMs); lastBlockSignature, rootSignature, createdAtMs);
std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, gDerivedKeys.rootPub, unsignedHash); std::vector<uint8_t> edRootData = buildEd25519InstructionData(rootSignature, gDerivedKeys.rootPub, unsignedHash);
std::vector<uint8_t> edBchData = buildEd25519InstructionData(lastBlockSignature, gDerivedKeys.blockchainPub, lastBlockHash); 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( std::vector<uint8_t> message = buildLegacyMessage(
recentBlockhash, recentBlockhash,
gDerivedKeys.devicePub, gDerivedKeys.clientPub,
userPda, userPda,
inflowVault, inflowVault,
economyConfig, economyConfig,
@ -1277,7 +1280,7 @@ static bool registerHomeserverOnSolana(String &messageOut) {
edBchData, edBchData,
createData); createData);
uint8_t txSignature[64]; uint8_t txSignature[64];
if (!signMessageEd25519(message, gDerivedKeys.deviceSk, txSignature)) { if (!signMessageEd25519(message, gDerivedKeys.clientSk, txSignature)) {
messageOut = "Не удалось подписать Solana-транзакцию"; messageOut = "Не удалось подписать Solana-транзакцию";
return false; return false;
} }
@ -2107,7 +2110,7 @@ static void drawConfirmScreen() {
String text = "Выполнить действие?"; String text = "Выполнить действие?";
if (gConfirmTarget == CONFIRM_REGISTER) { if (gConfirmTarget == CONFIRM_REGISTER) {
title = "Регистрация"; title = "Регистрация";
text = "Отправить create_user_pda в Solana через device key этого устройства?"; text = "Отправить create_user_pda в Solana через client key этого устройства?";
} else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) { } else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) {
title = "Очистка"; title = "Очистка";
text = "Удалить секрет, кошелёк и статус регистрации?"; text = "Удалить секрет, кошелёк и статус регистрации?";

View File

@ -70,11 +70,11 @@
Фоновая логика: Фоновая логика:
- пока открыт `HOME`, экран сам обновляется примерно раз в секунду; - пока открыт `HOME`, экран сам обновляется примерно раз в секунду;
- при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; - при наличии `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: - для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE:
- авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`; - авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`;
- session key = публичный `homeserver key`; - session key = публичный `homeserver key`;
- подтверждение создания сессии подписывается `device key`; - подтверждение создания сессии подписывается `client key`;
- heartbeat выполняется `Ping` раз в минуту. - heartbeat выполняется `Ping` раз в минуту.
## SETTINGS_MENU ## SETTINGS_MENU
@ -150,12 +150,12 @@
- статусное сообщение; - статусное сообщение;
- текущий `Solana RPC` адрес; - текущий `Solana RPC` адрес;
- кнопку `SOLANA RPC`; - кнопку `SOLANA RPC`;
- текущий `Shine server` адрес; - текущий `SHiNE server login` или уже резолвленный адрес;
- кнопку `SHINE SERVER`. - кнопку `SHiNE SERVER LOGIN`, если обычный `user PDA` ещё не зарегистрирован.
Значения по умолчанию: Значения по умолчанию:
- Solana RPC: `https://api.devnet.solana.com` - Solana RPC: `https://api.devnet.solana.com`
- Shine server: `https://shineup.me` - SHiNE server login: `shineupme`
Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`. Нажатие на любую из двух кнопок открывает `TEXT_EDIT_SCREEN`.
@ -214,8 +214,8 @@
- `Root key priv (base58)`; - `Root key priv (base58)`;
- `Blockchain key (base58)`; - `Blockchain key (base58)`;
- `Blockchain key priv (base58)`; - `Blockchain key priv (base58)`;
- `Device key (base58)`; - `Client key (base58)`;
- `Device key priv (base58)`; - `Client key priv (base58)`;
- `Homeserver key (base58)`; - `Homeserver key (base58)`;
- `Homeserver key priv (base58)`; - `Homeserver key priv (base58)`;
- для каждого поля показывается формула derivation; - для каждого поля показывается формула derivation;
@ -229,7 +229,7 @@
Используется для: Используется для:
- пароля Wi-Fi; - пароля Wi-Fi;
- Solana RPC; - Solana RPC;
- Shine server. - SHiNE server login.
Показывает: Показывает:
- заголовок; - заголовок;
@ -291,7 +291,7 @@
Используется `Preferences` (NVS памяти ESP32): Используется `Preferences` (NVS памяти ESP32):
- `solana_rpc` - `solana_rpc`
- `shine_server` - `shine_server_login`
## Хранение аккаунта ## Хранение аккаунта

View File

@ -19,7 +19,7 @@
- локальный UI на тач-экране; - локальный UI на тач-экране;
- хранение настроек и секретов во внутренней памяти `ESP32` через `NVS`; - хранение настроек и секретов во внутренней памяти `ESP32` через `NVS`;
- русский текст в логике интерфейса; в текущей временной сборке отображение на экране идёт через ASCII-транслитерацию, потому что `U8g2`-шрифты на устройстве временно не рисуются; - экранный UI устройства работает на английском языке, потому что кириллица на этом шрифтовом пути пока не поддерживается стабильно;
- экран пополнения с реальным `solana:` URI и рисованием QR-кода через `LVGL`; - экран пополнения с реальным `solana:` URI и рисованием QR-кода через `LVGL`;
- реальное подключение к `Wi-Fi` по сохранённым `SSID/паролю`; - реальное подключение к `Wi-Fi` по сохранённым `SSID/паролю`;
- реальная проверка доступности `API`, `RPC` и `WS`-адресов; - реальная проверка доступности `API`, `RPC` и `WS`-адресов;
@ -65,19 +65,20 @@
- `user pda address`; - `user pda address`;
- `registration signature`; - `registration signature`;
- `balance`; - `balance`;
- `server api url`; - `server login` для первичной привязки;
- `server rpc url`; - `resolved server api url` / `rpc url` / `ws url` после чтения PDA сервера;
- `server ws url`;
- флаги: - флаги:
`wifiReady`, `serversReady`, `secretReady`, `registered`, `online`. `wifiReady`, `serversReady`, `secretReady`, `registered`, `online`.
Для первой регистрации обычного `user PDA` устройство берёт `createdAtMs` из NTP прямо перед отправкой транзакции в Solana. При последующих обновлениях `user PDA` устройство так же берёт актуальный `updatedAtMs` из NTP перед отправкой update-транзакции. Дальше в `user PDA` сохраняется `accessServers`, где по умолчанию лежит `shineupme`.
## Правило серверной сессии SHiNE ## Правило серверной сессии SHiNE
При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс: При подключении к серверу `SHiNE` устройство должно авторизовываться как homeserver-сеанс:
- `sessionType = 100` - `sessionType = 100`
- `clientPlatform = "ESP32"` - `clientPlatform = "ESP32"`
- `clientInfo = "ESP32 homeserver"` - `clientInfo = "ESP32 homeserver:<homeserverName>"`
Это относится и к `CreateAuthSession`, и к `SessionLogin`. Это относится и к `CreateAuthSession`, и к `SessionLogin`.
@ -86,7 +87,7 @@
Кнопка регистрации доступна только если одновременно выполнены условия: Кнопка регистрации доступна только если одновременно выполнены условия:
1. настроен и подтверждён `Wi-Fi`; 1. настроен и подтверждён `Wi-Fi`;
2. заполнены и подтверждены серверные адреса; 2. задан и подтверждён `SHiNE server login`;
3. задан логин; 3. задан логин;
4. сгенерирован или введён секрет; 4. сгенерирован или введён секрет;
5. баланс кошелька не меньше `0.20 SOL`; 5. баланс кошелька не меньше `0.20 SOL`;
@ -107,14 +108,15 @@
7. `ACCOUNT` 7. `ACCOUNT`
8. `WALLET` 8. `WALLET`
9. `WALLET_QR` 9. `WALLET_QR`
10. `REQUESTS` 10. `WALLET_SIGN_REQUEST`
11. `REQUEST_DETAIL` 11. `REQUESTS`
12. `SETTINGS` 12. `REQUEST_DETAIL`
13. `PIN_EDIT` 13. `SETTINGS`
14. `TEXT_INPUT` 14. `PIN_EDIT`
15. `CONFIRM` 15. `TEXT_INPUT`
16. `REGISTER_ACCOUNT_CONFIRM` 16. `CONFIRM`
17. `REGISTER_ACCOUNT_RESULT` 17. `REGISTER_ACCOUNT_CONFIRM`
18. `REGISTER_ACCOUNT_RESULT`
## Общие правила интерфейса ## Общие правила интерфейса
@ -125,9 +127,9 @@
- затем, если устройство реально заряжается, маленькая иконка молнии; - затем, если устройство реально заряжается, маленькая иконка молнии;
- затем контур батареи; - затем контур батареи;
- затем индикатор `Wi-Fi`. - затем индикатор `Wi-Fi`.
- Основной язык прототипа: русский. - Основной язык прототипа: английский.
- Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`. - Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`.
- Русские строки на экране временно показываются в ASCII-транслитерации. - Русские строки в UI устройства пока не использовать.
- Кнопки крупные, с тач-ориентированным размером. - Кнопки крупные, с тач-ориентированным размером.
- Опасные действия подтверждаются отдельным диалогом. - Опасные действия подтверждаются отдельным диалогом.
- После изменения данных конфигурация сразу сохраняется в `NVS`. - После изменения данных конфигурация сразу сохраняется в `NVS`.
@ -172,20 +174,23 @@
- блок `STATUS` поднят выше относительно предыдущей версии; - блок `STATUS` поднят выше относительно предыдущей версии;
- если состояние хорошее, слово `connected` в строках `Wi-Fi` и `SHiNE` показывается зелёным. - если состояние хорошее, слово `connected` в строках `Wi-Fi` и `SHiNE` показывается зелёным.
В зоне баланса: В зоне кошелька:
- основная кнопка показа/обновления баланса занимает примерно 80% строки; - вместо старых двух кнопок `баланс` и `QR` показывается одна широкая кнопка;
- текст на кнопке баланса выровнен левее центра; - текст кнопки: `Wallet: <selected wallet name>`;
- справа от неё стоит отдельная кнопка `QR`; - доступные имена:
- после старта устройства баланс пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`; - `ClientKey`
- нажатие на кнопку `QR` открывает экран `WALLET_QR`. - `RootKey`
- либо сохранённое имя `custom`-кошелька;
- после старта устройства баланс активного выбранного кошелька пытается загрузиться автоматически, если уже есть секрет и `Wi-Fi`;
- нажатие на эту кнопку открывает экран `WALLET`.
Нижние кнопки: Нижние кнопки:
- `Статус` - `Статус`
- `Подключение` - `Подключение`
- `Аккаунт` - `Аккаунт`
- `Кошелёк` - `Wallet`
- `Запросы` - `Запросы`
- `Настройки` - `Настройки`
@ -410,26 +415,43 @@
Показывает: Показывает:
- адрес кошелька устройства; - кнопку `Wallet: <selected wallet name>`;
- баланс в `SOL`; - строку текущего статуса/баланса активного кошелька;
- минимально рекомендуемую сумму для регистрации; - сокращённый публичный ключ активного кошелька.
- статус `Хватает / Не хватает`.
Кнопки: Кнопки:
- `QR и URI` - `Wallet: <selected wallet name>`
- `+0.10 SOL` - `SHOW BALANCE`
- `+0.25 SOL` - `SHOW WALLET QR`
- `-0.10 SOL`
- `Проверить`
- `Назад`
Поведение: Поведение:
- кнопки пополнения/уменьшения нужны для теста сценариев; - верхняя кнопка открывает экран выбора кошелька `WALLET_SELECT`;
- `Проверить` читает реальный баланс из `Solana RPC`; - `Показать баланс кошелька` читает реальный баланс именно активного выбранного кошелька из `Solana RPC`;
- адрес кошелька должен совпадать с `device key`, вычисленным из сохранённого `master secret`; - `Показать QR-код кошелька` открывает экран `WALLET_QR`;
- отрицательный баланс не допускается. - этот экран не меняет логику Solana-регистрации пользователя: on-chain регистрация и проверки `PDA` по-прежнему завязаны на `client key`.
## Экран WALLET_SELECT
Показывает:
- строку `Current: <selected wallet name>`;
- три кнопки выбора:
- `ClientKey`
- `RootKey`
- `Custom` или `Custom: <имя>`;
- у текущего выбора видна галочка.
Поведение:
- `ClientKey` активирует кошелёк, выведенный из suffix `client.key`;
- `RootKey` активирует кошелёк, выведенный из suffix `root.key`;
- `Custom` использует derivation:
`sha256(base64(secret32) + "|wallet." + customName)`;
- если имя `custom`-кошелька ещё не задано, нажатие `Custom` открывает экран текстового ввода;
- если `custom` уже выбран, повторное нажатие на него открывает экран редактирования имени;
- после выбора кошелька устройство возвращается на экран `WALLET`.
## Экран WALLET_QR ## Экран WALLET_QR
@ -452,8 +474,29 @@
Поведение: Поведение:
- QR должен быть сканируемым, а не декоративным; - QR должен быть сканируемым, а не декоративным;
- адрес кошелька берётся из `device key`, вычисленного из сохранённого `master secret`; - адрес кошелька берётся из текущего активного выбранного кошелька;
- нажатие в любую точку экрана возвращает пользователя на `HOME`. - нажатие в любую точку экрана возвращает пользователя на `WALLET`.
## Экран WALLET_SIGN_REQUEST
Экран показывается, когда браузерное wallet-расширение присылает на ESP32 запрос `sign_transaction`.
Показывает:
- заголовок `SIGN REQUEST`;
- вопрос о подписи транзакции текущим активным кошельком;
- сокращённый публичный ключ кошелька;
- комментарий, присланный полем `comment`;
- две кнопки:
- `REJECT`
- `APPROVE`
Поведение:
- если пользователь нажимает `APPROVE`, ESP32 подписывает транзакцию текущим активным кошельком и отправляет ответ в расширение;
- если пользователь нажимает `REJECT`, ESP32 отправляет отказ `rejected_by_user`;
- пока запрос активен, свайпом этот экран не закрывается;
- после ответа расширению устройство возвращается на предыдущий экран.
## Экран REQUESTS ## Экран REQUESTS
@ -491,7 +534,7 @@
- строку `Session: <platform/name>`; - строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`; - строку `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.` - для wallet session: `No keys will be transferred.`
Кнопки: Кнопки:
@ -502,7 +545,7 @@
Поведение: Поведение:
- `YES` подтверждает заявку: - `YES` подтверждает заявку:
- для client session устройство передаёт только `device key`; - для client session устройство передаёт только `client key`;
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей; - для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
- `NO` отклоняет заявку; - `NO` отклоняет заявку;
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его; - после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
@ -586,7 +629,7 @@
2. открыть `Подключение -> Wi-Fi`; 2. открыть `Подключение -> Wi-Fi`;
3. ввести `SSID` и пароль, нажать `Проверить`; 3. ввести `SSID` и пароль, нажать `Проверить`;
4. открыть `Подключение -> Серверы`; 4. открыть `Подключение -> Серверы`;
5. проверить или задать серверные адреса; 5. проверить или задать `SHiNE server login` (по умолчанию `shineupme`);
6. открыть `Аккаунт`; 6. открыть `Аккаунт`;
7. ввести логин; 7. ввести логин;
8. задать имя homeserver; 8. задать имя homeserver;
@ -595,14 +638,15 @@
11. при необходимости пополнить баланс; 11. при необходимости пополнить баланс;
12. вернуться на `HOME`; 12. вернуться на `HOME`;
13. нажать `REGISTER ACCOUNT`; 13. нажать `REGISTER ACCOUNT`;
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1` и при необходимости сообщение о неподключённом `Wi-Fi`; 14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1`, серверный login и при необходимости сообщение о неподключённом `Wi-Fi`;
15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`; 15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`;
16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку; 16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку;
17. после успешной регистрации увидеть статус `Homeserver активен`. 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`.
## Сценарий входящего запроса ## Сценарий входящего запроса
@ -616,8 +660,8 @@
В текущей диагностической версии: В текущей диагностической версии:
- строковые литералы в коде остаются русскими и в `UTF-8`; - строковые литералы экранного UI должны оставаться английскими ASCII-совместимыми;
- перед выводом на экран они временно транслитерируются в ASCII; - возвращение кириллицы допустимо только после отдельной доработки шрифтов и реальной проверки на устройстве;
- рендер выполняется стандартным шрифтом `Arduino_GFX`; - рендер выполняется стандартным шрифтом `Arduino_GFX`;
- это обходной режим, пока `U8g2`-шрифты на устройстве не начнут рисоваться стабильно. - это обходной режим, пока `U8g2`-шрифты на устройстве не начнут рисоваться стабильно.
@ -626,7 +670,7 @@
Минимально нужно проверить: Минимально нужно проверить:
1. устройство загружается и сразу открывает `HOME`; экран блокировки временно отключён; 1. устройство загружается и сразу открывает `HOME`; экран блокировки временно отключён;
2. текст отображается читаемо хотя бы в ASCII-транслитерации; 2. весь экранный текст отображается читаемо на английском без замены символов;
3. ввод по экранной клавиатуре работает; 3. ввод по экранной клавиатуре работает;
4. после перезагрузки сохранённые поля остаются в памяти; 4. после перезагрузки сохранённые поля остаются в памяти;
5. секрет и адрес кошелька сохраняются на устройстве; 5. секрет и адрес кошелька сохраняются на устройстве;

View File

@ -7,11 +7,13 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
- создать `wallet-session` через `StartTrustedDeviceLogin`; - создать `wallet-session` через `StartTrustedDeviceLogin`;
- показать код подключения; - показать код подключения;
- дождаться подтверждения на доверенном устройстве; - дождаться подтверждения на доверенном устройстве;
- принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`; - принять `session-only` payload без передачи `clientKey/rootKey/blockchainKey`;
- сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin; - сохранить `sessionPriv/sessionKey/sessionId` в локальном хранилище plugin;
- восстанавливать session через `SessionChallenge -> SessionLogin`; - восстанавливать session через `SessionChallenge -> SessionLogin`;
- держать wallet-state в `background service worker`, а popup использовать как UI. - держать wallet-state в `background service worker`, а side panel использовать как UI.
- принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA. - принимать не адрес сервера, а логин серверного аккаунта SHiNE и находить точный `https://...` / `wss://...` адрес через его PDA.
- внедрять legacy `window.solana` / `window.phantom.solana` provider для сайтов.
- регистрировать кошелёк как `Wallet Standard` wallet для dapp, которые ищут стандартные кошельки.
## Как загрузить локально ## Как загрузить локально
@ -19,13 +21,16 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
2. Включи `Developer mode` 2. Включи `Developer mode`
3. Нажми `Load unpacked` 3. Нажми `Load unpacked`
4. Выбери папку `SHiNE-browser-plugin-wallet/` 4. Выбери папку `SHiNE-browser-plugin-wallet/`
5. Нажми на иконку расширения в toolbar: откроется side panel SHiNE Wallet
## Ограничения текущего этапа ## Ограничения текущего этапа
- plugin пока не держит постоянный фоновый WS-канал после закрытия popup, но хранит wallet-state в `background`; - plugin пока не держит постоянный фоновый WS-канал без активного действия пользователя, но хранит wallet-state в `background`;
- на этом этапе реализован только `session-only login`; - на этом этапе реализован только `session-only login`;
- запросы на подпись будут следующим этапом. - запросы на подпись будут следующим этапом.
- pairing-пароль, если он используется, должен генерироваться в формате `sha256$<hex>` от строки `shine-pairing|loginLower|password`. - pairing-пароль, если он используется, должен генерироваться в формате `sha256$<hex>` от строки `shine-pairing|loginLower|password`.
- сторона side panel в Chromium выбирается самим браузером/пользователем; extension не закрепляет панель принудительно слева.
- для совместимости с некоторыми dapp расширение одновременно держит и legacy provider, и Wallet Standard регистрацию.
## Сборка crypto bundle ## Сборка crypto bundle

View File

@ -26,19 +26,101 @@ const state = {
connectionOnline: false, connectionOnline: false,
walletProfile: null, walletProfile: null,
signing: { signing: {
selectedKeyId: 'device',
selectedDeviceName: '', selectedDeviceName: '',
devicesResolvedAtMs: 0, devicesResolvedAtMs: 0,
}, },
currentWallet: null,
pendingApprovals: [],
siteApprovalChain: Promise.resolve(),
sessionAttachInProgress: false,
statusText: '', statusText: '',
statusKind: 'info', statusKind: 'info',
}; };
const WALLET_RPC_REQUEST_TYPE = 9100;
const WALLET_RPC_RESPONSE_TYPE = 9101;
async function configureSidePanelBehavior() {
if (!chrome.sidePanel?.setPanelBehavior) {
return;
}
try {
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
} catch (error) {
console.warn('Failed to configure SHiNE side panel behavior:', error);
}
}
function normalizeOrigin(value = '') {
const raw = String(value || '').trim();
if (!raw) return '';
try {
return new URL(raw).origin;
} catch {
return raw;
}
}
function makeCodeError(message, code) {
const error = new Error(String(message || 'Wallet error'));
error.code = String(code || '').trim().toUpperCase();
return error;
}
function setStatus(message = '', kind = 'info') { function setStatus(message = '', kind = 'info') {
state.statusText = String(message || ''); state.statusText = String(message || '');
state.statusKind = kind === 'error' ? 'error' : '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() { function stopPoll() {
if (state.pollTimer) { if (state.pollTimer) {
clearTimeout(state.pollTimer); clearTimeout(state.pollTimer);
@ -71,14 +153,18 @@ async function loadStateFromStorage() {
serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'), serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'),
serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws', serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
login: String(settings?.login || '').trim(), 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.walletProfile = state.activeSession?.walletProfile || null;
state.signing = { state.signing = {
selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'),
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''), selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0), devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
}; };
state.currentWallet = state.activeSession?.currentWallet || null;
} }
async function persistSettings(nextSettings = {}) { async function persistSettings(nextSettings = {}) {
@ -86,10 +172,29 @@ async function persistSettings(nextSettings = {}) {
...state.settings, ...state.settings,
...nextSettings, ...nextSettings,
}; };
if (!Array.isArray(state.settings.connectedOrigins)) {
state.settings.connectedOrigins = [];
}
await savePluginSettings(state.settings); await savePluginSettings(state.settings);
return state.settings; return state.settings;
} }
function isOriginApproved(origin) {
const normalized = normalizeOrigin(origin);
return !!normalized && Array.isArray(state.settings.connectedOrigins) && state.settings.connectedOrigins.includes(normalized);
}
async function setOriginApproved(origin, approved) {
const normalized = normalizeOrigin(origin);
const current = new Set(Array.isArray(state.settings.connectedOrigins) ? state.settings.connectedOrigins : []);
if (approved) {
if (normalized) current.add(normalized);
} else if (normalized) {
current.delete(normalized);
}
await persistSettings({ connectedOrigins: [...current] });
}
async function resolveServerForLogin(login) { async function resolveServerForLogin(login) {
const cleanLogin = String(login || state.settings.login || '').trim(); const cleanLogin = String(login || state.settings.login || '').trim();
if (!cleanLogin) { if (!cleanLogin) {
@ -119,9 +224,9 @@ async function saveActiveSessionRecord() {
const nextRecord = { const nextRecord = {
...state.activeSession, ...state.activeSession,
walletProfile: state.walletProfile, walletProfile: state.walletProfile,
selectedKeyId: state.signing.selectedKeyId,
selectedDeviceName: state.signing.selectedDeviceName, selectedDeviceName: state.signing.selectedDeviceName,
devicesResolvedAtMs: state.signing.devicesResolvedAtMs, devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
currentWallet: state.currentWallet,
}; };
state.activeSession = nextRecord; state.activeSession = nextRecord;
await saveSessionMaterial(nextRecord); await saveSessionMaterial(nextRecord);
@ -152,27 +257,10 @@ function toWalletErrorMessage(error, fallback = 'Не удалось выпол
return raw || fallback; return raw || fallback;
} }
function buildSigningKeyOptions(walletProfile) { function homeserverSessionNameFromClientInfo(value = '') {
const rootKey = String(walletProfile?.publicKeys?.rootKeyBase58 || '').trim(); const raw = String(value || '').trim();
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim(); const match = raw.match(/^ESP32 homeserver:(.+)$/i);
const options = []; return match ? String(match[1] || '').trim() : '';
if (rootKey) {
options.push({
id: 'root',
label: `rootKey (ed25519, ${shortKey(rootKey)})`,
keyType: 'ed25519',
publicKeyBase58: rootKey,
});
}
if (deviceKey) {
options.push({
id: 'device',
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
keyType: 'ed25519',
publicKeyBase58: deviceKey,
});
}
return options;
} }
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) { function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
@ -181,18 +269,25 @@ function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = [])
? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100) ? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100)
: []; : [];
const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer); const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer);
const byName = new Map();
onlineHomeservers.forEach((item) => {
const sessionName = homeserverSessionNameFromClientInfo(item?.clientInfoFromClient);
if (sessionName) {
byName.set(sessionName, item);
}
});
return published.map((item) => { return published.map((item) => {
let onlineState = 'unknown'; const matched = byName.get(String(item?.sessionName || '').trim()) || null;
if (published.length === 1) { let onlineState = matched ? 'online' : 'offline';
onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline'; let activeSessionId = matched?.sessionId ? String(matched.sessionId) : '';
} else if (onlineHomeservers.length === 0) { if (!matched && published.length === 1 && onlineHomeservers.length === 1) {
onlineState = 'offline';
} else if (onlineHomeservers.length === published.length) {
onlineState = 'online'; onlineState = 'online';
activeSessionId = String(onlineHomeservers[0]?.sessionId || '');
} }
return { return {
...item, ...item,
activeSessionId,
onlineState, onlineState,
onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown', onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown',
}; };
@ -203,25 +298,20 @@ async function hydrateWalletProfile(login) {
const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim(); const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim();
if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.'); if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.');
const profile = await readWalletProfileByLogin(cleanLogin); const profile = await readWalletProfileByLogin(cleanLogin);
const signingKeyOptions = buildSigningKeyOptions(profile);
const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId)
? state.signing.selectedKeyId
: (signingKeyOptions[0]?.id || '');
const selectedDeviceName = state.signing.selectedDeviceName const selectedDeviceName = state.signing.selectedDeviceName
|| String(profile?.homeserverSessions?.[0]?.sessionName || ''); || String(profile?.homeserverSessions?.[0]?.sessionName || '');
state.walletProfile = { state.walletProfile = {
...profile, ...profile,
signingKeyOptions,
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({ homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
...item, ...item,
onlineState: 'unknown', onlineState: 'unknown',
onlineLabel: 'unknown', onlineLabel: 'unknown',
activeSessionId: '',
})) : [], })) : [],
}; };
state.signing = { state.signing = {
...state.signing, ...state.signing,
selectedKeyId,
selectedDeviceName, selectedDeviceName,
}; };
await saveActiveSessionRecord(); await saveActiveSessionRecord();
@ -282,17 +372,30 @@ async function attachApprovedSession(payload) {
throw new Error('Получен неполный session-only payload'); throw new Error('Получен неполный session-only payload');
} }
await clearSessionMaterial(); state.sessionAttachInProgress = true;
state.activeSession = sessionRecord; try {
await hydrateWalletProfile(login); state.activeSession = sessionRecord;
await saveActiveSessionRecord(); state.walletProfile = null;
await persistSettings({ state.currentWallet = null;
login: sessionRecord.login, state.signing = {
serverLogin: sessionRecord.serverLogin, ...state.signing,
serverHttp: sessionRecord.serverHttp, selectedDeviceName: '',
serverUrl: sessionRecord.serverUrl, devicesResolvedAtMs: 0,
}); };
state.connectionOnline = false; await saveActiveSessionRecord();
await hydrateWalletProfile(login);
await saveActiveSessionRecord();
await persistSettings({
login: sessionRecord.login,
serverLogin: sessionRecord.serverLogin,
serverHttp: sessionRecord.serverHttp,
serverUrl: sessionRecord.serverUrl,
});
state.connectionOnline = false;
state.currentWallet = null;
} finally {
state.sessionAttachInProgress = false;
}
} }
async function pollPairingStatus() { async function pollPairingStatus() {
@ -400,10 +503,10 @@ async function disconnectSession() {
state.connectionOnline = false; state.connectionOnline = false;
state.walletProfile = null; state.walletProfile = null;
state.signing = { state.signing = {
selectedKeyId: 'device',
selectedDeviceName: '', selectedDeviceName: '',
devicesResolvedAtMs: 0, devicesResolvedAtMs: 0,
}; };
state.currentWallet = null;
setStatus('Сохранённая wallet-session удалена из plugin.', 'info'); setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
return { ok: true }; return { ok: true };
} }
@ -440,40 +543,296 @@ async function refreshWalletDevices() {
} }
} }
async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) { async function updateSigningSelection({ selectedDeviceName } = {}) {
state.signing = { state.signing = {
...state.signing, ...state.signing,
selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''),
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''), selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
}; };
await saveActiveSessionRecord(); await saveActiveSessionRecord();
return { ok: true }; return { ok: true };
} }
async function prepareSignSignal() { async function resolveSelectedHomeserverSession() {
if (!state.activeSession?.login) { if (!state.activeSession?.login) {
throw new Error('Сначала подключите wallet-session.'); throw new Error('Сначала подключите wallet-session.');
} }
if (!state.signing.selectedKeyId) {
throw new Error('Не выбран ключ подписи.');
}
if (!state.signing.selectedDeviceName) { if (!state.signing.selectedDeviceName) {
throw new Error('Не выбрано устройство homeserver.'); throw new Error('Не выбрано устройство homeserver.');
} }
const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName); let selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
if (!selectedDevice) { if (!selectedDevice) {
throw new Error('Выбранное устройство не найдено в PDA аккаунта.'); throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
} }
setStatus( if (!selectedDevice.activeSessionId) {
`Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`, await refreshWalletDevices();
'info', selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
); }
if (!selectedDevice?.activeSessionId) {
throw new Error('Выбранный homeserver сейчас не найден онлайн на сервере SHiNE.');
}
return selectedDevice;
}
async function callWalletRpc(requestData, timeoutMs = 8000, abortSignal = null) {
const selectedDevice = await resolveSelectedHomeserverSession();
const resumed = await resumeActiveSession({ keepConnected: true });
if (!resumed.ok) {
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
}
const requestId = String(requestData?.requestId || `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`);
const callId = `wallet-rpc-${requestId}`;
const payload = {
...requestData,
requestId,
timeMs: Number(requestData?.timeMs || Date.now()),
};
try {
const response = await new Promise((resolve, reject) => {
let settled = false;
let timeoutId = 0;
let off = () => {};
let removeAbortListener = () => {};
const cleanup = () => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
off();
removeAbortListener();
};
timeoutId = setTimeout(() => {
cleanup();
reject(new Error('Таймаут ответа от ESP32.'));
}, timeoutMs);
off = ensureApi().onEvent('IncomingCallSignal', (evt) => {
const eventPayload = evt?.payload || {};
if (String(eventPayload?.callId || '') !== callId) return;
if (Number(eventPayload?.type || 0) !== WALLET_RPC_RESPONSE_TYPE) return;
cleanup();
try {
resolve(JSON.parse(String(eventPayload?.data || '{}')));
} catch {
reject(new Error('ESP32 вернул некорректный JSON.'));
}
});
if (abortSignal) {
const onAbort = () => {
cleanup();
reject(abortSignal.reason instanceof Error ? abortSignal.reason : new Error('Ожидание подписи отменено.'));
};
if (abortSignal.aborted) {
onAbort();
return;
}
abortSignal.addEventListener('abort', onAbort, { once: true });
removeAbortListener = () => {
abortSignal.removeEventListener('abort', onAbort);
};
}
ensureApi().callSignalToSession({
toLogin: state.activeSession.login,
targetSessionId: selectedDevice.activeSessionId,
callId,
type: WALLET_RPC_REQUEST_TYPE,
data: JSON.stringify(payload),
}).catch((error) => {
cleanup();
reject(error);
});
});
return { response, selectedDevice, requestId };
} finally {
ensureApi().close();
state.api = null;
state.connectionOnline = false;
}
}
function verifyWalletAgainstPda(wallet) {
const type = String(wallet?.type || '').trim();
const pub = String(wallet?.publicKeyBase58 || '').trim();
const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const clientKey = String(
state.walletProfile?.publicKeys?.clientKeyBase58 || '',
).trim();
if (type === 'client.key') {
return {
verified: !!clientKey && clientKey === pub,
verificationText: clientKey === pub ? 'Совпадает с clientKey из PDA.' : 'Не совпадает с clientKey из PDA.',
};
}
if (type === 'root.key') {
return {
verified: !!rootKey && rootKey === pub,
verificationText: rootKey === pub ? 'Совпадает с rootKey из PDA.' : 'Не совпадает с rootKey из PDA.',
};
}
return {
verified: null,
verificationText: 'Для custom-кошелька проверка через PDA пока не выполняется.',
};
}
async function requestCurrentWallet() {
const { response, selectedDevice, requestId } = await callWalletRpc({
v: 1,
operation: 'get_wallet_public_key',
requestId: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
});
if (!response?.ok) {
throw new Error(`ESP32 отклонил запрос: ${String(response?.error || 'unknown_error')}`);
}
state.currentWallet = {
type: String(response?.wallet?.type || '').trim(),
publicKeyBase58: String(response?.wallet?.publicKeyBase58 || '').trim(),
homeserverName: selectedDevice.sessionName,
requestId: String(response?.requestId || requestId),
timeMs: Number(response?.timeMs || 0),
...verifyWalletAgainstPda(response?.wallet || {}),
};
await saveActiveSessionRecord();
setStatus(`Кошелёк получен с ${selectedDevice.sessionName}.`, 'info');
return { ok: true, wallet: state.currentWallet };
}
async function cancelPendingSiteApproval() {
const pending = getCurrentPendingApproval();
if (!pending) {
setStatus('Сейчас нет активного ожидания подписи.', 'info');
return { ok: true };
}
removePendingApproval(pending.id, {
rejectError: makeCodeError('User canceled request in extension.', 'USER_REJECTED'),
});
setStatus('Ожидание подписи отменено в расширении.', 'info');
return { ok: true };
}
async function markPendingSiteApprovalResolved(pendingId) {
removePendingApproval(pendingId);
}
function enqueueSiteApproval(work) {
const run = state.siteApprovalChain.then(work, work);
state.siteApprovalChain = run.catch(() => {});
return run;
}
async function activatePendingApproval(pending, sender = null) {
const abortController = new AbortController();
pending.status = 'active';
pending.abortController = abortController;
pending.timeoutId = setTimeout(() => {
removePendingApproval(pending.id, {
rejectError: makeCodeError('Signing request timed out in extension.', 'USER_REJECTED'),
});
setStatus('Ожидание подписи истекло в расширении.', 'error');
}, 120000);
setStatus(`Сайт ${pending.origin} запросил подпись. Подтвердите или отмените на доверенном устройстве.`, 'info');
await openSidePanelForSender(sender);
return pending;
}
function beginSiteTransactionFlow(payload = {}) {
const pending = makePendingApprovalSnapshot({
...payload,
kind: 'sign_transaction',
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
createdAtMs: Date.now(),
status: 'queued',
});
state.pendingApprovals.push({
...pending,
timeoutId: 0,
abortController: null,
});
return pending;
}
async function siteConnect({ origin, onlyIfTrusted = false } = {}) {
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) {
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
}
if (onlyIfTrusted && !isOriginApproved(normalizedOrigin)) {
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
}
const result = await requestCurrentWallet();
const publicKeyBase58 = String(result?.wallet?.publicKeyBase58 || '').trim();
if (!publicKeyBase58) {
throw makeCodeError('Wallet public key is not available.', 'WALLET_UNAVAILABLE');
}
if (!isOriginApproved(normalizedOrigin)) {
await setOriginApproved(normalizedOrigin, true);
}
setStatus(`Site ${normalizedOrigin} connected to ${shortKey(publicKeyBase58, 8)}.`, 'info');
return { return {
ok: true, ok: true,
pending: true, publicKeyBase58,
walletType: String(result?.wallet?.type || '').trim(),
}; };
} }
async function siteDisconnect({ origin } = {}) {
const normalizedOrigin = normalizeOrigin(origin);
setStatus(normalizedOrigin ? `Site ${normalizedOrigin} disconnected.` : 'Site disconnected.', 'info');
return { ok: true };
}
async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment, transactionSummary } = {}, sender = null) {
const normalizedOrigin = normalizeOrigin(origin);
if (!normalizedOrigin) {
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
}
if (!isOriginApproved(normalizedOrigin)) {
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
}
const cleanPub = String(publicKeyBase58 || '').trim();
const cleanTx = String(transactionBase64 || '').trim();
if (!cleanPub || !cleanTx) {
throw makeCodeError('Transaction payload is incomplete.', 'BAD_REQUEST');
}
const pending = beginSiteTransactionFlow({
origin: normalizedOrigin,
publicKeyBase58: cleanPub,
comment: String(comment || '').trim(),
transactionSummary: transactionSummary || null,
});
return enqueueSiteApproval(async () => {
await activatePendingApproval(getCurrentPendingApproval() || pending, sender);
const activePending = getCurrentPendingApproval() || pending;
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const signComment = String(comment || '').trim() || `Site ${normalizedOrigin} requested transaction signature`;
try {
const { response } = await callWalletRpc({
v: 1,
operation: 'sign_transaction',
requestId,
publicKeyBase58: cleanPub,
transactionBase64: cleanTx,
comment: signComment,
}, 120000, activePending.abortController?.signal || null);
if (!response?.ok) {
const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase();
if (errorCode === 'REJECTED_BY_USER') {
throw makeCodeError('User rejected transaction signature on ESP32.', 'USER_REJECTED');
}
throw makeCodeError(`ESP32 rejected transaction signature: ${String(response?.error || 'unknown_error')}`, errorCode || 'RPC_REJECTED');
}
setStatus(`Подпись для ${normalizedOrigin} завершена.`, 'info');
return {
ok: true,
publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(),
signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(),
signatureBase58: String(response?.signatureBase58 || '').trim(),
};
} finally {
await markPendingSiteApprovalResolved(activePending.id);
}
});
}
function snapshot() { function snapshot() {
return { return {
settings: { ...state.settings }, settings: { ...state.settings },
@ -485,8 +844,10 @@ function snapshot() {
trustedSessionOnline: state.trustedSessionOnline, trustedSessionOnline: state.trustedSessionOnline,
}, },
session: state.activeSession ? { ...state.activeSession } : null, session: state.activeSession ? { ...state.activeSession } : null,
connectionOnline: state.connectionOnline, connectionOnline: !!state.activeSession,
walletProfile: state.walletProfile ? { ...state.walletProfile } : null, walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
currentWallet: state.currentWallet ? { ...state.currentWallet } : null,
pendingApproval: getCurrentPendingApproval() ? makePendingApprovalSnapshot(getCurrentPendingApproval()) : null,
signing: { ...state.signing }, signing: { ...state.signing },
status: { status: {
text: state.statusText, text: state.statusText,
@ -538,8 +899,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ ok: true, result, state: snapshot() }); sendResponse({ ok: true, result, state: snapshot() });
return; return;
} }
if (type === 'wallet:prepareSignSignal') { if (type === 'wallet:requestCurrentWallet') {
const result = await prepareSignSignal(); const result = await requestCurrentWallet();
sendResponse({ ok: true, result, state: snapshot() }); sendResponse({ ok: true, result, state: snapshot() });
return; return;
} }
@ -548,15 +909,45 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ ok: true, result, state: snapshot() }); sendResponse({ ok: true, result, state: snapshot() });
return; return;
} }
if (type === 'wallet:cancelPendingSiteApproval') {
const result = await cancelPendingSiteApproval();
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteConnect') {
const result = await siteConnect(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteDisconnect') {
const result = await siteDisconnect(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:siteSignTransaction') {
const result = await siteSignTransaction(message?.payload || {}, _sender);
sendResponse({ ok: true, result, state: snapshot() });
return;
}
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' }); sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
})().catch((error) => { })().catch((error) => {
const message = toWalletErrorMessage(error, 'Unknown error'); const message = toWalletErrorMessage(error, 'Unknown error');
setStatus(message, 'error'); setStatus(message, 'error');
sendResponse({ ok: false, error: message, state: snapshot() }); sendResponse({ ok: false, error: message, code: String(error?.code || ''), state: snapshot() });
}); });
return true; return true;
}); });
chrome.runtime.onInstalled.addListener(() => {
void configureSidePanelBehavior();
});
chrome.runtime.onStartup.addListener(() => {
void configureSidePanelBehavior();
});
void configureSidePanelBehavior();
void loadStateFromStorage().then(async () => { void loadStateFromStorage().then(async () => {
if (state.activeSession?.login) { if (state.activeSession?.login) {
await hydrateWalletProfile(state.activeSession.login).catch(() => {}); await hydrateWalletProfile(state.activeSession.login).catch(() => {});

View File

@ -0,0 +1,93 @@
const PAGE_REQUEST = 'shine-wallet-page-request';
const PAGE_RESPONSE = 'shine-wallet-page-response';
const PAGE_MESSAGE_TARGET_ORIGIN = '*';
function injectProviderBridge() {
const root = document.head || document.documentElement;
if (!root) return;
const script = document.createElement('script');
script.type = 'module';
script.src = chrome.runtime.getURL('provider-bridge.js');
script.dataset.shineWalletProvider = '1';
root.appendChild(script);
script.remove();
}
function respondToPage(id, ok, result, error, code) {
window.postMessage({
target: PAGE_RESPONSE,
id: String(id || ''),
ok: !!ok,
result: result || null,
error: error ? String(error) : '',
code: code ? String(code) : '',
}, PAGE_MESSAGE_TARGET_ORIGIN);
}
function sendRuntimeMessage(type, payload = {}) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ type, payload }, (response) => {
if (chrome.runtime.lastError) {
const raw = String(chrome.runtime.lastError.message || 'Runtime message failed');
if (/Extension context invalidated/i.test(raw)) {
const error = new Error('Расширение было перезагружено или отключено. Обновите страницу и откройте кошелёк заново.');
error.code = 'EXTENSION_CONTEXT_INVALIDATED';
reject(error);
return;
}
reject(new Error(raw));
return;
}
if (!response?.ok) {
const error = new Error(String(response?.error || 'Wallet operation failed'));
error.code = String(response?.code || '');
reject(error);
return;
}
resolve(response);
});
});
}
window.addEventListener('message', (event) => {
if (event.source !== window) return;
const data = event.data || {};
if (data?.target !== PAGE_REQUEST) return;
const id = String(data?.id || '');
const method = String(data?.method || '');
const params = data?.params || {};
const origin = window.location.origin;
(async () => {
if (method === 'connect') {
const response = await sendRuntimeMessage('wallet:siteConnect', {
origin,
onlyIfTrusted: !!params?.onlyIfTrusted,
});
respondToPage(id, true, response.result || null);
return;
}
if (method === 'disconnect') {
const response = await sendRuntimeMessage('wallet:siteDisconnect', { origin });
respondToPage(id, true, response.result || null);
return;
}
if (method === 'signTransaction') {
const response = await sendRuntimeMessage('wallet:siteSignTransaction', {
origin,
publicKeyBase58: String(params?.publicKeyBase58 || '').trim(),
transactionBase64: String(params?.transactionBase64 || '').trim(),
comment: String(params?.comment || '').trim(),
transactionSummary: params?.transactionSummary || null,
});
respondToPage(id, true, response.result || null);
return;
}
respondToPage(id, false, null, 'Unsupported provider method', 'UNSUPPORTED_METHOD');
})().catch((error) => {
respondToPage(id, false, null, error?.message || 'Wallet bridge error', error?.code || '');
});
});
injectProviderBridge();

View File

@ -75,6 +75,22 @@ export class ShineApiClient {
return Array.isArray(response?.payload?.sessions) ? response.payload.sessions : []; return Array.isArray(response?.payload?.sessions) ? response.payload.sessions : [];
} }
async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) {
const response = await this.ws.request('CallSignalToSession', {
toLogin: String(toLogin || '').trim(),
targetSessionId: String(targetSessionId || '').trim(),
callId: String(callId || '').trim(),
type: Number(type) || 0,
data: String(data || ''),
});
if (response.status !== 200) throw opError('CallSignalToSession', response);
return response.payload || {};
}
onEvent(op, handler) {
return this.ws.on(op, handler);
}
async resumeSession(sessionRecord) { async resumeSession(sessionRecord) {
const login = String(sessionRecord?.login || '').trim(); const login = String(sessionRecord?.login || '').trim();
const sessionId = String(sessionRecord?.sessionId || '').trim(); const sessionId = String(sessionRecord?.sessionId || '').trim();

View File

@ -2,7 +2,7 @@ import { base64ToBytes } from './crypto-utils.js';
import { PublicKey } from './vendor/solana-publickey-bundle.js'; import { PublicKey } from './vendor/solana-publickey-bundle.js';
const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com'; 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 SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme'; const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme';
const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me'; const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me';
@ -69,8 +69,9 @@ function parseServerFieldsFromUserPda(dataBytes) {
let isServer = false; let isServer = false;
let serverAddress = ''; let serverAddress = '';
let accessServers = []; let accessServers = [];
let recoveryKey32 = null;
let rootKey32 = null; let rootKey32 = null;
let deviceKey32 = null; let clientKey32 = null;
let blockchainKey32 = null; let blockchainKey32 = null;
let blockchainName = ''; let blockchainName = '';
let homeserverSessions = []; let homeserverSessions = [];
@ -79,10 +80,11 @@ function parseServerFieldsFromUserPda(dataBytes) {
const blockType = readU8(bytes, cursorRef); const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version cursorRef.value += 1; // block_version
if (blockType === 1 || blockType === 2) { if (blockType === 0 || blockType === 1 || blockType === 2) {
const key32 = readBytes(bytes, cursorRef, 32); const key32 = readBytes(bytes, cursorRef, 32);
if (blockType === 0) recoveryKey32 = key32;
if (blockType === 1) rootKey32 = key32; if (blockType === 1) rootKey32 = key32;
if (blockType === 2) deviceKey32 = key32; if (blockType === 2) clientKey32 = key32;
continue; continue;
} }
if (blockType === 3) { if (blockType === 3) {
@ -150,8 +152,9 @@ function parseServerFieldsFromUserPda(dataBytes) {
serverAddress: normalizeHostLike(serverAddress), serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean), accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: { publicKeys: {
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).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() : '', blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName, blockchainName,
}, },

View File

@ -12136,8 +12136,8 @@ function weierstrass(curveDef) {
return drbg(seed, k2sig); return drbg(seed, k2sig);
} }
Point2.BASE._setWindowSize(8); Point2.BASE._setWindowSize(8);
function verify2(signature, msgHash, publicKey2, opts = defaultVerOpts) { function verify2(signature2, msgHash, publicKey2, opts = defaultVerOpts) {
const sg = signature; const sg = signature2;
msgHash = ensureBytes("msgHash", msgHash); msgHash = ensureBytes("msgHash", msgHash);
publicKey2 = ensureBytes("publicKey", publicKey2); publicKey2 = ensureBytes("publicKey", publicKey2);
if ("strict" in opts) if ("strict" in opts)
@ -12515,30 +12515,30 @@ var PACKET_DATA_SIZE = 1280 - 40 - 8;
var VERSION_PREFIX_MASK = 127; var VERSION_PREFIX_MASK = 127;
var SIGNATURE_LENGTH_IN_BYTES = 64; var SIGNATURE_LENGTH_IN_BYTES = 64;
var TransactionExpiredBlockheightExceededError = class extends Error { var TransactionExpiredBlockheightExceededError = class extends Error {
constructor(signature) { constructor(signature2) {
super(`Signature ${signature} has expired: block height exceeded.`); super(`Signature ${signature2} has expired: block height exceeded.`);
this.signature = void 0; this.signature = void 0;
this.signature = signature; this.signature = signature2;
} }
}; };
Object.defineProperty(TransactionExpiredBlockheightExceededError.prototype, "name", { Object.defineProperty(TransactionExpiredBlockheightExceededError.prototype, "name", {
value: "TransactionExpiredBlockheightExceededError" value: "TransactionExpiredBlockheightExceededError"
}); });
var TransactionExpiredTimeoutError = class extends Error { var TransactionExpiredTimeoutError = class extends Error {
constructor(signature, timeoutSeconds) { constructor(signature2, timeoutSeconds) {
super(`Transaction was not confirmed in ${timeoutSeconds.toFixed(2)} seconds. It is unknown if it succeeded or failed. Check signature ${signature} using the Solana Explorer or CLI tools.`); super(`Transaction was not confirmed in ${timeoutSeconds.toFixed(2)} seconds. It is unknown if it succeeded or failed. Check signature ${signature2} using the Solana Explorer or CLI tools.`);
this.signature = void 0; this.signature = void 0;
this.signature = signature; this.signature = signature2;
} }
}; };
Object.defineProperty(TransactionExpiredTimeoutError.prototype, "name", { Object.defineProperty(TransactionExpiredTimeoutError.prototype, "name", {
value: "TransactionExpiredTimeoutError" value: "TransactionExpiredTimeoutError"
}); });
var TransactionExpiredNonceInvalidError = class extends Error { var TransactionExpiredNonceInvalidError = class extends Error {
constructor(signature) { constructor(signature2) {
super(`Signature ${signature} has expired: the nonce is no longer valid.`); super(`Signature ${signature2} has expired: the nonce is no longer valid.`);
this.signature = void 0; this.signature = void 0;
this.signature = signature; this.signature = signature2;
} }
}; };
Object.defineProperty(TransactionExpiredNonceInvalidError.prototype, "name", { Object.defineProperty(TransactionExpiredNonceInvalidError.prototype, "name", {
@ -12598,6 +12598,9 @@ var MessageAccountKeys = class {
var publicKey = (property = "publicKey") => { var publicKey = (property = "publicKey") => {
return BufferLayout.blob(32, property); return BufferLayout.blob(32, property);
}; };
var signature = (property = "signature") => {
return BufferLayout.blob(64, property);
};
var rustString = (property = "string") => { var rustString = (property = "string") => {
const rsl = BufferLayout.struct([BufferLayout.u32("length"), BufferLayout.u32("lengthPadding"), BufferLayout.blob(BufferLayout.offset(BufferLayout.u32(), -8), "chars")], property); const rsl = BufferLayout.struct([BufferLayout.u32("length"), BufferLayout.u32("lengthPadding"), BufferLayout.blob(BufferLayout.offset(BufferLayout.u32(), -8), "chars")], property);
const _decode = rsl.decode.bind(rsl); const _decode = rsl.decode.bind(rsl);
@ -12954,6 +12957,260 @@ var Message = class _Message {
return new _Message(messageArgs); return new _Message(messageArgs);
} }
}; };
var MessageV0 = class _MessageV0 {
constructor(args) {
this.header = void 0;
this.staticAccountKeys = void 0;
this.recentBlockhash = void 0;
this.compiledInstructions = void 0;
this.addressTableLookups = void 0;
this.header = args.header;
this.staticAccountKeys = args.staticAccountKeys;
this.recentBlockhash = args.recentBlockhash;
this.compiledInstructions = args.compiledInstructions;
this.addressTableLookups = args.addressTableLookups;
}
get version() {
return 0;
}
get numAccountKeysFromLookups() {
let count = 0;
for (const lookup of this.addressTableLookups) {
count += lookup.readonlyIndexes.length + lookup.writableIndexes.length;
}
return count;
}
getAccountKeys(args) {
let accountKeysFromLookups;
if (args && "accountKeysFromLookups" in args && args.accountKeysFromLookups) {
if (this.numAccountKeysFromLookups != args.accountKeysFromLookups.writable.length + args.accountKeysFromLookups.readonly.length) {
throw new Error("Failed to get account keys because of a mismatch in the number of account keys from lookups");
}
accountKeysFromLookups = args.accountKeysFromLookups;
} else if (args && "addressLookupTableAccounts" in args && args.addressLookupTableAccounts) {
accountKeysFromLookups = this.resolveAddressTableLookups(args.addressLookupTableAccounts);
} else if (this.addressTableLookups.length > 0) {
throw new Error("Failed to get account keys because address table lookups were not resolved");
}
return new MessageAccountKeys(this.staticAccountKeys, accountKeysFromLookups);
}
isAccountSigner(index) {
return index < this.header.numRequiredSignatures;
}
isAccountWritable(index) {
const numSignedAccounts = this.header.numRequiredSignatures;
const numStaticAccountKeys = this.staticAccountKeys.length;
if (index >= numStaticAccountKeys) {
const lookupAccountKeysIndex = index - numStaticAccountKeys;
const numWritableLookupAccountKeys = this.addressTableLookups.reduce((count, lookup) => count + lookup.writableIndexes.length, 0);
return lookupAccountKeysIndex < numWritableLookupAccountKeys;
} else if (index >= this.header.numRequiredSignatures) {
const unsignedAccountIndex = index - numSignedAccounts;
const numUnsignedAccounts = numStaticAccountKeys - numSignedAccounts;
const numWritableUnsignedAccounts = numUnsignedAccounts - this.header.numReadonlyUnsignedAccounts;
return unsignedAccountIndex < numWritableUnsignedAccounts;
} else {
const numWritableSignedAccounts = numSignedAccounts - this.header.numReadonlySignedAccounts;
return index < numWritableSignedAccounts;
}
}
resolveAddressTableLookups(addressLookupTableAccounts) {
const accountKeysFromLookups = {
writable: [],
readonly: []
};
for (const tableLookup of this.addressTableLookups) {
const tableAccount = addressLookupTableAccounts.find((account) => account.key.equals(tableLookup.accountKey));
if (!tableAccount) {
throw new Error(`Failed to find address lookup table account for table key ${tableLookup.accountKey.toBase58()}`);
}
for (const index of tableLookup.writableIndexes) {
if (index < tableAccount.state.addresses.length) {
accountKeysFromLookups.writable.push(tableAccount.state.addresses[index]);
} else {
throw new Error(`Failed to find address for index ${index} in address lookup table ${tableLookup.accountKey.toBase58()}`);
}
}
for (const index of tableLookup.readonlyIndexes) {
if (index < tableAccount.state.addresses.length) {
accountKeysFromLookups.readonly.push(tableAccount.state.addresses[index]);
} else {
throw new Error(`Failed to find address for index ${index} in address lookup table ${tableLookup.accountKey.toBase58()}`);
}
}
}
return accountKeysFromLookups;
}
static compile(args) {
const compiledKeys = CompiledKeys.compile(args.instructions, args.payerKey);
const addressTableLookups = new Array();
const accountKeysFromLookups = {
writable: new Array(),
readonly: new Array()
};
const lookupTableAccounts = args.addressLookupTableAccounts || [];
for (const lookupTable of lookupTableAccounts) {
const extractResult = compiledKeys.extractTableLookup(lookupTable);
if (extractResult !== void 0) {
const [addressTableLookup, {
writable,
readonly
}] = extractResult;
addressTableLookups.push(addressTableLookup);
accountKeysFromLookups.writable.push(...writable);
accountKeysFromLookups.readonly.push(...readonly);
}
}
const [header, staticAccountKeys] = compiledKeys.getMessageComponents();
const accountKeys = new MessageAccountKeys(staticAccountKeys, accountKeysFromLookups);
const compiledInstructions = accountKeys.compileInstructions(args.instructions);
return new _MessageV0({
header,
staticAccountKeys,
recentBlockhash: args.recentBlockhash,
compiledInstructions,
addressTableLookups
});
}
serialize() {
const encodedStaticAccountKeysLength = Array();
encodeLength(encodedStaticAccountKeysLength, this.staticAccountKeys.length);
const serializedInstructions = this.serializeInstructions();
const encodedInstructionsLength = Array();
encodeLength(encodedInstructionsLength, this.compiledInstructions.length);
const serializedAddressTableLookups = this.serializeAddressTableLookups();
const encodedAddressTableLookupsLength = Array();
encodeLength(encodedAddressTableLookupsLength, this.addressTableLookups.length);
const messageLayout = BufferLayout.struct([BufferLayout.u8("prefix"), BufferLayout.struct([BufferLayout.u8("numRequiredSignatures"), BufferLayout.u8("numReadonlySignedAccounts"), BufferLayout.u8("numReadonlyUnsignedAccounts")], "header"), BufferLayout.blob(encodedStaticAccountKeysLength.length, "staticAccountKeysLength"), BufferLayout.seq(publicKey(), this.staticAccountKeys.length, "staticAccountKeys"), publicKey("recentBlockhash"), BufferLayout.blob(encodedInstructionsLength.length, "instructionsLength"), BufferLayout.blob(serializedInstructions.length, "serializedInstructions"), BufferLayout.blob(encodedAddressTableLookupsLength.length, "addressTableLookupsLength"), BufferLayout.blob(serializedAddressTableLookups.length, "serializedAddressTableLookups")]);
const serializedMessage = new Uint8Array(PACKET_DATA_SIZE);
const MESSAGE_VERSION_0_PREFIX = 1 << 7;
const serializedMessageLength = messageLayout.encode({
prefix: MESSAGE_VERSION_0_PREFIX,
header: this.header,
staticAccountKeysLength: new Uint8Array(encodedStaticAccountKeysLength),
staticAccountKeys: this.staticAccountKeys.map((key) => key.toBytes()),
recentBlockhash: import_bs58.default.decode(this.recentBlockhash),
instructionsLength: new Uint8Array(encodedInstructionsLength),
serializedInstructions,
addressTableLookupsLength: new Uint8Array(encodedAddressTableLookupsLength),
serializedAddressTableLookups
}, serializedMessage);
return serializedMessage.slice(0, serializedMessageLength);
}
serializeInstructions() {
let serializedLength = 0;
const serializedInstructions = new Uint8Array(PACKET_DATA_SIZE);
for (const instruction of this.compiledInstructions) {
const encodedAccountKeyIndexesLength = Array();
encodeLength(encodedAccountKeyIndexesLength, instruction.accountKeyIndexes.length);
const encodedDataLength = Array();
encodeLength(encodedDataLength, instruction.data.length);
const instructionLayout = BufferLayout.struct([BufferLayout.u8("programIdIndex"), BufferLayout.blob(encodedAccountKeyIndexesLength.length, "encodedAccountKeyIndexesLength"), BufferLayout.seq(BufferLayout.u8(), instruction.accountKeyIndexes.length, "accountKeyIndexes"), BufferLayout.blob(encodedDataLength.length, "encodedDataLength"), BufferLayout.blob(instruction.data.length, "data")]);
serializedLength += instructionLayout.encode({
programIdIndex: instruction.programIdIndex,
encodedAccountKeyIndexesLength: new Uint8Array(encodedAccountKeyIndexesLength),
accountKeyIndexes: instruction.accountKeyIndexes,
encodedDataLength: new Uint8Array(encodedDataLength),
data: instruction.data
}, serializedInstructions, serializedLength);
}
return serializedInstructions.slice(0, serializedLength);
}
serializeAddressTableLookups() {
let serializedLength = 0;
const serializedAddressTableLookups = new Uint8Array(PACKET_DATA_SIZE);
for (const lookup of this.addressTableLookups) {
const encodedWritableIndexesLength = Array();
encodeLength(encodedWritableIndexesLength, lookup.writableIndexes.length);
const encodedReadonlyIndexesLength = Array();
encodeLength(encodedReadonlyIndexesLength, lookup.readonlyIndexes.length);
const addressTableLookupLayout = BufferLayout.struct([publicKey("accountKey"), BufferLayout.blob(encodedWritableIndexesLength.length, "encodedWritableIndexesLength"), BufferLayout.seq(BufferLayout.u8(), lookup.writableIndexes.length, "writableIndexes"), BufferLayout.blob(encodedReadonlyIndexesLength.length, "encodedReadonlyIndexesLength"), BufferLayout.seq(BufferLayout.u8(), lookup.readonlyIndexes.length, "readonlyIndexes")]);
serializedLength += addressTableLookupLayout.encode({
accountKey: lookup.accountKey.toBytes(),
encodedWritableIndexesLength: new Uint8Array(encodedWritableIndexesLength),
writableIndexes: lookup.writableIndexes,
encodedReadonlyIndexesLength: new Uint8Array(encodedReadonlyIndexesLength),
readonlyIndexes: lookup.readonlyIndexes
}, serializedAddressTableLookups, serializedLength);
}
return serializedAddressTableLookups.slice(0, serializedLength);
}
static deserialize(serializedMessage) {
let byteArray = [...serializedMessage];
const prefix = guardedShift(byteArray);
const maskedPrefix = prefix & VERSION_PREFIX_MASK;
assert2(prefix !== maskedPrefix, `Expected versioned message but received legacy message`);
const version2 = maskedPrefix;
assert2(version2 === 0, `Expected versioned message with version 0 but found version ${version2}`);
const header = {
numRequiredSignatures: guardedShift(byteArray),
numReadonlySignedAccounts: guardedShift(byteArray),
numReadonlyUnsignedAccounts: guardedShift(byteArray)
};
const staticAccountKeys = [];
const staticAccountKeysLength = decodeLength(byteArray);
for (let i2 = 0; i2 < staticAccountKeysLength; i2++) {
staticAccountKeys.push(new PublicKey(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH)));
}
const recentBlockhash = import_bs58.default.encode(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH));
const instructionCount = decodeLength(byteArray);
const compiledInstructions = [];
for (let i2 = 0; i2 < instructionCount; i2++) {
const programIdIndex = guardedShift(byteArray);
const accountKeyIndexesLength = decodeLength(byteArray);
const accountKeyIndexes = guardedSplice(byteArray, 0, accountKeyIndexesLength);
const dataLength = decodeLength(byteArray);
const data = new Uint8Array(guardedSplice(byteArray, 0, dataLength));
compiledInstructions.push({
programIdIndex,
accountKeyIndexes,
data
});
}
const addressTableLookupsCount = decodeLength(byteArray);
const addressTableLookups = [];
for (let i2 = 0; i2 < addressTableLookupsCount; i2++) {
const accountKey = new PublicKey(guardedSplice(byteArray, 0, PUBLIC_KEY_LENGTH));
const writableIndexesLength = decodeLength(byteArray);
const writableIndexes = guardedSplice(byteArray, 0, writableIndexesLength);
const readonlyIndexesLength = decodeLength(byteArray);
const readonlyIndexes = guardedSplice(byteArray, 0, readonlyIndexesLength);
addressTableLookups.push({
accountKey,
writableIndexes,
readonlyIndexes
});
}
return new _MessageV0({
header,
staticAccountKeys,
recentBlockhash,
compiledInstructions,
addressTableLookups
});
}
};
var VersionedMessage = {
deserializeMessageVersion(serializedMessage) {
const prefix = serializedMessage[0];
const maskedPrefix = prefix & VERSION_PREFIX_MASK;
if (maskedPrefix === prefix) {
return "legacy";
}
return maskedPrefix;
},
deserialize: (serializedMessage) => {
const version2 = VersionedMessage.deserializeMessageVersion(serializedMessage);
if (version2 === "legacy") {
return Message.from(serializedMessage);
}
if (version2 === 0) {
return MessageV0.deserialize(serializedMessage);
} else {
throw new Error(`Transaction message version ${version2} deserialization is not supported`);
}
}
};
var DEFAULT_SIGNATURE = import_buffer2.Buffer.alloc(SIGNATURE_LENGTH_IN_BYTES).fill(0); var DEFAULT_SIGNATURE = import_buffer2.Buffer.alloc(SIGNATURE_LENGTH_IN_BYTES).fill(0);
var TransactionInstruction = class { var TransactionInstruction = class {
constructor(opts) { constructor(opts) {
@ -13196,9 +13453,9 @@ var Transaction = class _Transaction {
isWritable: true isWritable: true
}); });
} }
for (const signature of this.signatures) { for (const signature2 of this.signatures) {
const uniqueIndex = uniqueMetas.findIndex((x) => { const uniqueIndex = uniqueMetas.findIndex((x) => {
return x.pubkey.equals(signature.publicKey); return x.pubkey.equals(signature2.publicKey);
}); });
if (uniqueIndex > -1) { if (uniqueIndex > -1) {
if (!uniqueMetas[uniqueIndex].isSigner) { if (!uniqueMetas[uniqueIndex].isSigner) {
@ -13206,7 +13463,7 @@ var Transaction = class _Transaction {
console.warn("Transaction references a signature that is unnecessary, only the fee payer and instruction signer accounts should sign a transaction. This behavior is deprecated and will throw an error in the next major version release."); console.warn("Transaction references a signature that is unnecessary, only the fee payer and instruction signer accounts should sign a transaction. This behavior is deprecated and will throw an error in the next major version release.");
} }
} else { } else {
throw new Error(`unknown signer: ${signature.publicKey.toString()}`); throw new Error(`unknown signer: ${signature2.publicKey.toString()}`);
} }
} }
let numRequiredSignatures = 0; let numRequiredSignatures = 0;
@ -13392,8 +13649,8 @@ var Transaction = class _Transaction {
_partialSign(message, ...signers) { _partialSign(message, ...signers) {
const signData = message.serialize(); const signData = message.serialize();
signers.forEach((signer) => { signers.forEach((signer) => {
const signature = sign(signData, signer.secretKey); const signature2 = sign(signData, signer.secretKey);
this._addSignature(signer.publicKey, toBuffer(signature)); this._addSignature(signer.publicKey, toBuffer(signature2));
}); });
} }
/** /**
@ -13404,20 +13661,20 @@ var Transaction = class _Transaction {
* @param {PublicKey} pubkey Public key that will be added to the transaction. * @param {PublicKey} pubkey Public key that will be added to the transaction.
* @param {Buffer} signature An externally created signature to add to the transaction. * @param {Buffer} signature An externally created signature to add to the transaction.
*/ */
addSignature(pubkey, signature) { addSignature(pubkey, signature2) {
this._compile(); this._compile();
this._addSignature(pubkey, signature); this._addSignature(pubkey, signature2);
} }
/** /**
* @internal * @internal
*/ */
_addSignature(pubkey, signature) { _addSignature(pubkey, signature2) {
assert2(signature.length === 64); assert2(signature2.length === 64);
const index = this.signatures.findIndex((sigpair) => pubkey.equals(sigpair.publicKey)); const index = this.signatures.findIndex((sigpair) => pubkey.equals(sigpair.publicKey));
if (index < 0) { if (index < 0) {
throw new Error(`unknown signer: ${pubkey.toString()}`); throw new Error(`unknown signer: ${pubkey.toString()}`);
} }
this.signatures[index].signature = import_buffer2.Buffer.from(signature); this.signatures[index].signature = import_buffer2.Buffer.from(signature2);
} }
/** /**
* Verify signatures of a Transaction * Verify signatures of a Transaction
@ -13436,15 +13693,15 @@ var Transaction = class _Transaction {
_getMessageSignednessErrors(message, requireAllSignatures) { _getMessageSignednessErrors(message, requireAllSignatures) {
const errors = {}; const errors = {};
for (const { for (const {
signature, signature: signature2,
publicKey: publicKey2 publicKey: publicKey2
} of this.signatures) { } of this.signatures) {
if (signature === null) { if (signature2 === null) {
if (requireAllSignatures) { if (requireAllSignatures) {
(errors.missing ||= []).push(publicKey2); (errors.missing ||= []).push(publicKey2);
} }
} else { } else {
if (!verify(signature, message, publicKey2.toBytes())) { if (!verify(signature2, message, publicKey2.toBytes())) {
(errors.invalid ||= []).push(publicKey2); (errors.invalid ||= []).push(publicKey2);
} }
} }
@ -13498,11 +13755,11 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
assert2(signatures.length < 256); assert2(signatures.length < 256);
import_buffer2.Buffer.from(signatureCount).copy(wireTransaction, 0); import_buffer2.Buffer.from(signatureCount).copy(wireTransaction, 0);
signatures.forEach(({ signatures.forEach(({
signature signature: signature2
}, index) => { }, index) => {
if (signature !== null) { if (signature2 !== null) {
assert2(signature.length === 64, `signature has invalid length`); assert2(signature2.length === 64, `signature has invalid length`);
import_buffer2.Buffer.from(signature).copy(wireTransaction, signatureCount.length + index * 64); import_buffer2.Buffer.from(signature2).copy(wireTransaction, signatureCount.length + index * 64);
} }
}); });
signData.copy(wireTransaction, signatureCount.length + signatures.length * 64); signData.copy(wireTransaction, signatureCount.length + signatures.length * 64);
@ -13545,8 +13802,8 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
const signatureCount = decodeLength(byteArray); const signatureCount = decodeLength(byteArray);
let signatures = []; let signatures = [];
for (let i2 = 0; i2 < signatureCount; i2++) { for (let i2 = 0; i2 < signatureCount; i2++) {
const signature = guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES); const signature2 = guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES);
signatures.push(import_bs58.default.encode(import_buffer2.Buffer.from(signature))); signatures.push(import_bs58.default.encode(import_buffer2.Buffer.from(signature2)));
} }
return _Transaction.populate(Message.from(byteArray), signatures); return _Transaction.populate(Message.from(byteArray), signatures);
} }
@ -13564,9 +13821,9 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
if (message.header.numRequiredSignatures > 0) { if (message.header.numRequiredSignatures > 0) {
transaction.feePayer = message.accountKeys[0]; transaction.feePayer = message.accountKeys[0];
} }
signatures.forEach((signature, index) => { signatures.forEach((signature2, index) => {
const sigPubkeyPair = { const sigPubkeyPair = {
signature: signature == import_bs58.default.encode(DEFAULT_SIGNATURE) ? null : import_bs58.default.decode(signature), signature: signature2 == import_bs58.default.encode(DEFAULT_SIGNATURE) ? null : import_bs58.default.decode(signature2),
publicKey: message.accountKeys[index] publicKey: message.accountKeys[index]
}; };
transaction.signatures.push(sigPubkeyPair); transaction.signatures.push(sigPubkeyPair);
@ -13591,6 +13848,65 @@ Missing signature for public key${sigErrors.missing.length === 1 ? "" : "(s)"} [
return transaction; return transaction;
} }
}; };
var VersionedTransaction = class _VersionedTransaction {
get version() {
return this.message.version;
}
constructor(message, signatures) {
this.signatures = void 0;
this.message = void 0;
if (signatures !== void 0) {
assert2(signatures.length === message.header.numRequiredSignatures, "Expected signatures length to be equal to the number of required signatures");
this.signatures = signatures;
} else {
const defaultSignatures = [];
for (let i2 = 0; i2 < message.header.numRequiredSignatures; i2++) {
defaultSignatures.push(new Uint8Array(SIGNATURE_LENGTH_IN_BYTES));
}
this.signatures = defaultSignatures;
}
this.message = message;
}
serialize() {
const serializedMessage = this.message.serialize();
const encodedSignaturesLength = Array();
encodeLength(encodedSignaturesLength, this.signatures.length);
const transactionLayout = BufferLayout.struct([BufferLayout.blob(encodedSignaturesLength.length, "encodedSignaturesLength"), BufferLayout.seq(signature(), this.signatures.length, "signatures"), BufferLayout.blob(serializedMessage.length, "serializedMessage")]);
const serializedTransaction = new Uint8Array(2048);
const serializedTransactionLength = transactionLayout.encode({
encodedSignaturesLength: new Uint8Array(encodedSignaturesLength),
signatures: this.signatures,
serializedMessage
}, serializedTransaction);
return serializedTransaction.slice(0, serializedTransactionLength);
}
static deserialize(serializedTransaction) {
let byteArray = [...serializedTransaction];
const signatures = [];
const signaturesLength = decodeLength(byteArray);
for (let i2 = 0; i2 < signaturesLength; i2++) {
signatures.push(new Uint8Array(guardedSplice(byteArray, 0, SIGNATURE_LENGTH_IN_BYTES)));
}
const message = VersionedMessage.deserialize(new Uint8Array(byteArray));
return new _VersionedTransaction(message, signatures);
}
sign(signers) {
const messageData = this.message.serialize();
const signerPubkeys = this.message.staticAccountKeys.slice(0, this.message.header.numRequiredSignatures);
for (const signer of signers) {
const signerIndex = signerPubkeys.findIndex((pubkey) => pubkey.equals(signer.publicKey));
assert2(signerIndex >= 0, `Cannot sign with non signer key ${signer.publicKey.toBase58()}`);
this.signatures[signerIndex] = sign(messageData, signer.secretKey);
}
}
addSignature(publicKey2, signature2) {
assert2(signature2.byteLength === 64, "Signature must be 64 bytes long");
const signerPubkeys = this.message.staticAccountKeys.slice(0, this.message.header.numRequiredSignatures);
const signerIndex = signerPubkeys.findIndex((pubkey) => pubkey.equals(publicKey2));
assert2(signerIndex >= 0, `Can not add signature; \`${publicKey2.toBase58()}\` is not required to sign this transaction`);
this.signatures[signerIndex] = signature2;
}
};
var NUM_TICKS_PER_SECOND = 160; var NUM_TICKS_PER_SECOND = 160;
var DEFAULT_TICKS_PER_SLOT = 64; var DEFAULT_TICKS_PER_SLOT = 64;
var NUM_SLOTS_PER_SECOND = NUM_TICKS_PER_SECOND / DEFAULT_TICKS_PER_SLOT; var NUM_SLOTS_PER_SECOND = NUM_TICKS_PER_SECOND / DEFAULT_TICKS_PER_SLOT;
@ -13607,7 +13923,7 @@ var SYSVAR_STAKE_HISTORY_PUBKEY = new PublicKey("SysvarStakeHistory1111111111111
var SendTransactionError = class extends Error { var SendTransactionError = class extends Error {
constructor({ constructor({
action, action,
signature, signature: signature2,
transactionMessage, transactionMessage,
logs logs
}) { }) {
@ -13617,7 +13933,7 @@ ${JSON.stringify(logs.slice(-10), null, 2)}. ` : "";
let message; let message;
switch (action) { switch (action) {
case "send": case "send":
message = `Transaction ${signature} resulted in an error. message = `Transaction ${signature2} resulted in an error.
${transactionMessage}. ` + maybeLogsOutput + guideText; ${transactionMessage}. ` + maybeLogsOutput + guideText;
break; break;
case "simulate": case "simulate":
@ -13633,7 +13949,7 @@ Message: ${transactionMessage}.
this.signature = void 0; this.signature = void 0;
this.transactionMessage = void 0; this.transactionMessage = void 0;
this.transactionLogs = void 0; this.transactionLogs = void 0;
this.signature = signature; this.signature = signature2;
this.transactionMessage = transactionMessage; this.transactionMessage = transactionMessage;
this.transactionLogs = logs ? logs : void 0; this.transactionLogs = logs ? logs : void 0;
} }
@ -13675,12 +13991,12 @@ async function sendAndConfirmTransaction(connection, transaction, signers, optio
maxRetries: options.maxRetries, maxRetries: options.maxRetries,
minContextSlot: options.minContextSlot minContextSlot: options.minContextSlot
}; };
const signature = await connection.sendTransaction(transaction, signers, sendOptions); const signature2 = await connection.sendTransaction(transaction, signers, sendOptions);
let status; let status;
if (transaction.recentBlockhash != null && transaction.lastValidBlockHeight != null) { if (transaction.recentBlockhash != null && transaction.lastValidBlockHeight != null) {
status = (await connection.confirmTransaction({ status = (await connection.confirmTransaction({
abortSignal: options?.abortSignal, abortSignal: options?.abortSignal,
signature, signature: signature2,
blockhash: transaction.recentBlockhash, blockhash: transaction.recentBlockhash,
lastValidBlockHeight: transaction.lastValidBlockHeight lastValidBlockHeight: transaction.lastValidBlockHeight
}, options && options.commitment)).value; }, options && options.commitment)).value;
@ -13694,25 +14010,25 @@ async function sendAndConfirmTransaction(connection, transaction, signers, optio
minContextSlot: transaction.minNonceContextSlot, minContextSlot: transaction.minNonceContextSlot,
nonceAccountPubkey, nonceAccountPubkey,
nonceValue: transaction.nonceInfo.nonce, nonceValue: transaction.nonceInfo.nonce,
signature signature: signature2
}, options && options.commitment)).value; }, options && options.commitment)).value;
} else { } else {
if (options?.abortSignal != null) { if (options?.abortSignal != null) {
console.warn("sendAndConfirmTransaction(): A transaction with a deprecated confirmation strategy was supplied along with an `abortSignal`. Only transactions having `lastValidBlockHeight` or a combination of `nonceInfo` and `minNonceContextSlot` are abortable."); console.warn("sendAndConfirmTransaction(): A transaction with a deprecated confirmation strategy was supplied along with an `abortSignal`. Only transactions having `lastValidBlockHeight` or a combination of `nonceInfo` and `minNonceContextSlot` are abortable.");
} }
status = (await connection.confirmTransaction(signature, options && options.commitment)).value; status = (await connection.confirmTransaction(signature2, options && options.commitment)).value;
} }
if (status.err) { if (status.err) {
if (signature != null) { if (signature2 != null) {
throw new SendTransactionError({ throw new SendTransactionError({
action: "send", action: "send",
signature, signature: signature2,
transactionMessage: `Status: (${JSON.stringify(status)})` transactionMessage: `Status: (${JSON.stringify(status)})`
}); });
} }
throw new Error(`Transaction ${signature} failed (${JSON.stringify(status)})`); throw new Error(`Transaction ${signature2} failed (${JSON.stringify(status)})`);
} }
return signature; return signature2;
} }
function sleep(ms) { function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@ -15218,14 +15534,14 @@ var Ed25519Program = class _Ed25519Program {
const { const {
publicKey: publicKey2, publicKey: publicKey2,
message, message,
signature, signature: signature2,
instructionIndex instructionIndex
} = params; } = params;
assert2(publicKey2.length === PUBLIC_KEY_BYTES$1, `Public Key must be ${PUBLIC_KEY_BYTES$1} bytes but received ${publicKey2.length} bytes`); assert2(publicKey2.length === PUBLIC_KEY_BYTES$1, `Public Key must be ${PUBLIC_KEY_BYTES$1} bytes but received ${publicKey2.length} bytes`);
assert2(signature.length === SIGNATURE_BYTES, `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature.length} bytes`); assert2(signature2.length === SIGNATURE_BYTES, `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature2.length} bytes`);
const publicKeyOffset = ED25519_INSTRUCTION_LAYOUT.span; const publicKeyOffset = ED25519_INSTRUCTION_LAYOUT.span;
const signatureOffset = publicKeyOffset + publicKey2.length; const signatureOffset = publicKeyOffset + publicKey2.length;
const messageDataOffset = signatureOffset + signature.length; const messageDataOffset = signatureOffset + signature2.length;
const numSignatures = 1; const numSignatures = 1;
const instructionData = import_buffer2.Buffer.alloc(messageDataOffset + message.length); const instructionData = import_buffer2.Buffer.alloc(messageDataOffset + message.length);
const index = instructionIndex == null ? 65535 : instructionIndex; const index = instructionIndex == null ? 65535 : instructionIndex;
@ -15241,7 +15557,7 @@ var Ed25519Program = class _Ed25519Program {
messageInstructionIndex: index messageInstructionIndex: index
}, instructionData); }, instructionData);
instructionData.fill(publicKey2, publicKeyOffset); instructionData.fill(publicKey2, publicKeyOffset);
instructionData.fill(signature, signatureOffset); instructionData.fill(signature2, signatureOffset);
instructionData.fill(message, messageDataOffset); instructionData.fill(message, messageDataOffset);
return new TransactionInstruction({ return new TransactionInstruction({
keys: [], keys: [],
@ -15263,11 +15579,11 @@ var Ed25519Program = class _Ed25519Program {
try { try {
const keypair = Keypair.fromSecretKey(privateKey); const keypair = Keypair.fromSecretKey(privateKey);
const publicKey2 = keypair.publicKey.toBytes(); const publicKey2 = keypair.publicKey.toBytes();
const signature = sign(message, keypair.secretKey); const signature2 = sign(message, keypair.secretKey);
return this.createInstructionWithPublicKey({ return this.createInstructionWithPublicKey({
publicKey: publicKey2, publicKey: publicKey2,
message, message,
signature, signature: signature2,
instructionIndex instructionIndex
}); });
} catch (error) { } catch (error) {
@ -15277,8 +15593,8 @@ var Ed25519Program = class _Ed25519Program {
}; };
Ed25519Program.programId = new PublicKey("Ed25519SigVerify111111111111111111111111111"); Ed25519Program.programId = new PublicKey("Ed25519SigVerify111111111111111111111111111");
var ecdsaSign = (msgHash, privKey) => { var ecdsaSign = (msgHash, privKey) => {
const signature = secp256k1.sign(msgHash, privKey); const signature2 = secp256k1.sign(msgHash, privKey);
return [signature.toCompactRawBytes(), signature.recovery]; return [signature2.toCompactRawBytes(), signature2.recovery];
}; };
secp256k1.utils.isValidPrivateKey; secp256k1.utils.isValidPrivateKey;
var publicKeyCreate = secp256k1.getPublicKey; var publicKeyCreate = secp256k1.getPublicKey;
@ -15316,14 +15632,14 @@ var Secp256k1Program = class _Secp256k1Program {
const { const {
publicKey: publicKey2, publicKey: publicKey2,
message, message,
signature, signature: signature2,
recoveryId, recoveryId,
instructionIndex instructionIndex
} = params; } = params;
return _Secp256k1Program.createInstructionWithEthAddress({ return _Secp256k1Program.createInstructionWithEthAddress({
ethAddress: _Secp256k1Program.publicKeyToEthAddress(publicKey2), ethAddress: _Secp256k1Program.publicKeyToEthAddress(publicKey2),
message, message,
signature, signature: signature2,
recoveryId, recoveryId,
instructionIndex instructionIndex
}); });
@ -15336,7 +15652,7 @@ var Secp256k1Program = class _Secp256k1Program {
const { const {
ethAddress: rawAddress, ethAddress: rawAddress,
message, message,
signature, signature: signature2,
recoveryId, recoveryId,
instructionIndex = 0 instructionIndex = 0
} = params; } = params;
@ -15354,7 +15670,7 @@ var Secp256k1Program = class _Secp256k1Program {
const dataStart = 1 + SIGNATURE_OFFSETS_SERIALIZED_SIZE; const dataStart = 1 + SIGNATURE_OFFSETS_SERIALIZED_SIZE;
const ethAddressOffset = dataStart; const ethAddressOffset = dataStart;
const signatureOffset = dataStart + ethAddress.length; const signatureOffset = dataStart + ethAddress.length;
const messageDataOffset = signatureOffset + signature.length + 1; const messageDataOffset = signatureOffset + signature2.length + 1;
const numSignatures = 1; const numSignatures = 1;
const instructionData = import_buffer2.Buffer.alloc(SECP256K1_INSTRUCTION_LAYOUT.span + message.length); const instructionData = import_buffer2.Buffer.alloc(SECP256K1_INSTRUCTION_LAYOUT.span + message.length);
SECP256K1_INSTRUCTION_LAYOUT.encode({ SECP256K1_INSTRUCTION_LAYOUT.encode({
@ -15366,7 +15682,7 @@ var Secp256k1Program = class _Secp256k1Program {
messageDataOffset, messageDataOffset,
messageDataSize: message.length, messageDataSize: message.length,
messageInstructionIndex: instructionIndex, messageInstructionIndex: instructionIndex,
signature: toBuffer(signature), signature: toBuffer(signature2),
ethAddress: toBuffer(ethAddress), ethAddress: toBuffer(ethAddress),
recoveryId recoveryId
}, instructionData); }, instructionData);
@ -15396,11 +15712,11 @@ var Secp256k1Program = class _Secp256k1Program {
/* isCompressed */ /* isCompressed */
).slice(1); ).slice(1);
const messageHash = import_buffer2.Buffer.from(keccak_256(toBuffer(message))); const messageHash = import_buffer2.Buffer.from(keccak_256(toBuffer(message)));
const [signature, recoveryId] = ecdsaSign(messageHash, privateKey); const [signature2, recoveryId] = ecdsaSign(messageHash, privateKey);
return this.createInstructionWithPublicKey({ return this.createInstructionWithPublicKey({
publicKey: publicKey2, publicKey: publicKey2,
message, message,
signature, signature: signature2,
recoveryId, recoveryId,
instructionIndex instructionIndex
}); });
@ -16179,7 +16495,9 @@ var VoteAccountLayout = BufferLayout.struct([
BufferLayout.struct([BufferLayout.nu64("slot"), BufferLayout.nu64("timestamp")], "lastTimestamp") BufferLayout.struct([BufferLayout.nu64("slot"), BufferLayout.nu64("timestamp")], "lastTimestamp")
]); ]);
export { export {
PublicKey PublicKey,
Transaction,
VersionedTransaction
}; };
/*! Bundled license information: /*! Bundled license information:

View File

@ -1,3 +1,3 @@
import { PublicKey } from '@solana/web3.js'; import { PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js';
export { PublicKey }; export { PublicKey, Transaction, VersionedTransaction };

View File

@ -24,6 +24,7 @@ export class WsJsonClient {
this.ws = null; this.ws = null;
this.openPromise = null; this.openPromise = null;
this.pending = new Map(); this.pending = new Map();
this.listeners = new Map();
} }
async open() { async open() {
@ -78,14 +79,53 @@ export class WsJsonClient {
} catch { } catch {
return; return;
} }
if (data?.event) {
this.emit(String(data?.op || ''), data);
return;
}
const requestId = data?.requestId; const requestId = data?.requestId;
if (!requestId) return; if (!requestId) {
this.emit(String(data?.op || ''), data);
return;
}
const slot = this.pending.get(requestId); const slot = this.pending.get(requestId);
if (!slot) return; if (!slot) {
this.emit(String(data?.op || ''), data);
return;
}
this.pending.delete(requestId); this.pending.delete(requestId);
slot.resolve(data); slot.resolve(data);
} }
on(op, handler) {
const key = String(op || '').trim();
if (!key || typeof handler !== 'function') {
return () => {};
}
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key).add(handler);
return () => {
const bucket = this.listeners.get(key);
if (!bucket) return;
bucket.delete(handler);
if (!bucket.size) {
this.listeners.delete(key);
}
};
}
emit(op, payload) {
const bucket = this.listeners.get(String(op || '').trim());
if (!bucket?.size) return;
for (const handler of [...bucket]) {
try {
handler(payload);
} catch {}
}
}
failPending(message) { failPending(message) {
const error = new Error(message); const error = new Error(message);
for (const slot of this.pending.values()) slot.reject(error); for (const slot of this.pending.values()) slot.reject(error);

View File

@ -4,7 +4,8 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Wallet-session plugin for SHiNE with session-only login via trusted device.", "description": "Wallet-session plugin for SHiNE with session-only login via trusted device.",
"permissions": [ "permissions": [
"storage" "storage",
"sidePanel"
], ],
"host_permissions": [ "host_permissions": [
"<all_urls>" "<all_urls>"
@ -13,8 +14,32 @@
"service_worker": "background.js", "service_worker": "background.js",
"type": "module" "type": "module"
}, },
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"content-script.js"
],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": [
"provider-bridge.js",
"js/lib/vendor/solana-publickey-bundle.js"
],
"matches": [
"<all_urls>"
]
}
],
"action": { "action": {
"default_title": "SHiNE Wallet", "default_title": "Open SHiNE Wallet"
"default_popup": "popup.html" },
"side_panel": {
"default_path": "popup.html"
} }
} }

View File

@ -2,22 +2,34 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
min-height: 100%;
}
body { body {
margin: 0; margin: 0;
min-width: 360px; min-width: 320px;
max-width: none;
min-height: 100vh;
background: #0f1720; background: #0f1720;
color: #e8eef6; color: #e8eef6;
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
.side-panel-body {
width: 100%;
}
.layout { .layout {
padding: 12px; padding: 12px;
min-height: 100vh;
} }
.panel { .panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-height: calc(100vh - 24px);
} }
.panel-header { .panel-header {
@ -140,9 +152,9 @@ select {
} }
.code { .code {
font-size: 34px; font-size: 30px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.18em; letter-spacing: 0.12em;
} }
.summary-row { .summary-row {
@ -153,7 +165,7 @@ select {
} }
.summary-row code { .summary-row code {
max-width: 180px; max-width: 140px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -186,6 +198,12 @@ select {
gap: 8px; gap: 8px;
} }
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-row { .device-row {
padding: 8px 10px; padding: 8px 10px;
border: 1px solid #243446; border: 1px solid #243446;
@ -193,6 +211,30 @@ select {
background: #0d141d; 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 { .device-state {
font-size: 12px; font-size: 12px;
text-transform: lowercase; text-transform: lowercase;
@ -209,3 +251,15 @@ select {
.device-state-unknown { .device-state-unknown {
color: #f8e2a0; color: #f8e2a0;
} }
.wallet-pubkey {
display: block;
width: 100%;
padding: 10px 12px;
border: 1px solid #314459;
border-radius: 8px;
background: #0d141d;
color: #bed5f5;
white-space: normal;
line-break: anywhere;
}

View File

@ -6,7 +6,7 @@
<title>SHiNE Wallet</title> <title>SHiNE Wallet</title>
<link rel="stylesheet" href="./popup.css" /> <link rel="stylesheet" href="./popup.css" />
</head> </head>
<body> <body class="side-panel-body">
<main class="layout"> <main class="layout">
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
@ -14,49 +14,13 @@
<h1>SHiNE Wallet</h1> <h1>SHiNE Wallet</h1>
<p class="muted">Session-only wallet plugin</p> <p class="muted">Session-only wallet plugin</p>
</div> </div>
<span id="connection-pill" class="pill pill-offline">offline</span> <span id="connection-pill" class="pill pill-offline">не подключено</span>
</div> </div>
<p id="server-login-info" class="muted small">Сервер SHiNE: —</p> <p id="server-login-info" class="muted small">Сервер SHiNE: —</p>
<p id="server-address" class="muted small">Адрес: —</p>
<div id="session-card" class="card hidden"> <div id="connect-card" class="card">
<div class="card-title">Подключённая wallet-session</div> <div class="card-title">Подключение</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="summary-row"><span>Session ID</span><code id="session-id"></code></div>
<div class="summary-row"><span>Тип</span><strong id="session-type">wallet</strong></div>
<div class="summary-row"><span>deviceKey</span><code id="device-key-short"></code></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="refresh-devices-btn" class="btn secondary" type="button">Обновить устройства</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div id="signing-card" class="card hidden">
<div class="card-title">Подготовка подписи</div>
<label class="field">
<span>Ключ подписи</span>
<select id="sign-key-select"></select>
</label>
<label class="field">
<span>Устройство homeserver</span>
<select id="device-select"></select>
</label>
<div id="homeserver-list" class="device-list"></div>
<p class="muted small">
Для выбора доступны homeserver-сессии, опубликованные в PDA аккаунта. Online-статус определяется без постоянного удержания соединения.
</p>
<div class="actions">
<button id="prepare-sign-btn" class="btn primary" type="button">Запросить подпись</button>
</div>
<p class="muted small">
Сам signaling подтверждения подписи ещё не доделан. Сейчас доступен только каркас выбора ключа и устройства.
</p>
</div>
<div class="card">
<div class="card-title">Войти через другое устройство</div>
<label class="field"> <label class="field">
<span>Логин</span> <span>Логин</span>
<input id="login-input" type="text" autocomplete="username" /> <input id="login-input" type="text" autocomplete="username" />
@ -69,29 +33,68 @@
<span>Пароль подключения</span> <span>Пароль подключения</span>
<input id="password-input" type="password" autocomplete="current-password" /> <input id="password-input" type="password" autocomplete="current-password" />
</label> </label>
<button id="start-btn" class="btn primary" type="button">Получить код</button> <button id="start-btn" class="btn primary" type="button">Подключить</button>
<p class="muted small">
Wallet plugin создаёт временный requester keypair, ждёт подтверждение на доверенном устройстве
и получает только wallet-session без передачи постоянных ключей.
</p>
</div> </div>
<div id="pairing-card" class="card hidden"> <div id="pairing-card" class="card hidden">
<div class="card-title">Код подключения</div> <div class="card-title">Код подключения</div>
<div id="short-code" class="code">00 00 00 00 00</div> <div id="short-code" class="code">00 00 00 00 00</div>
<p id="pairing-hint" class="muted small"> <p id="pairing-hint" class="muted small">Покажите код на доверенном устройстве.</p>
Покажите код на доверенном устройстве в разделе «Подключить по коду».
</p>
<p id="pairing-expire" class="muted small"></p> <p id="pairing-expire" class="muted small"></p>
<div class="actions"> <div class="actions">
<button id="cancel-btn" class="btn secondary" type="button">Отменить</button> <button id="cancel-btn" class="btn secondary" type="button">Отменить</button>
</div> </div>
</div> </div>
<div id="session-card" class="card hidden">
<div class="card-title">Подключено</div>
<div class="summary-row"><span>Логин</span><strong id="session-login"></strong></div>
<div class="actions">
<button id="resume-btn" class="btn secondary" type="button">Проверить session</button>
<button id="disconnect-btn" class="btn danger" type="button">Отключить</button>
</div>
</div>
<div id="wallet-card" class="card hidden">
<div class="card-title">Текущий кошелёк ESP32</div>
<label class="field">
<span>Homeserver</span>
<select id="device-select"></select>
</label>
<div id="homeserver-list" class="device-list"></div>
<div class="actions">
<button id="refresh-devices-btn" class="btn secondary" type="button">Обновить устройства</button>
<button id="request-wallet-btn" class="btn primary" type="button">Запросить кошелёк</button>
</div>
</div>
<div id="pending-approval-card" class="card hidden">
<div class="card-title">Ожидается подпись</div>
<p id="pending-approval-subtitle" class="muted small">Сайт запросил подписание транзакции.</p>
<div id="pending-approval-details" class="device-list"></div>
<p class="muted small">Запрос уже отправлен на доверенное устройство. Здесь можно только отменить ожидание.</p>
<div class="actions">
<button id="cancel-pending-approval-btn" class="btn danger" type="button">Отменить</button>
</div>
</div>
<div id="wallet-result-card" class="card hidden">
<div class="card-title">Полученный кошелёк</div>
<div class="summary-row"><span>Тип</span><strong id="wallet-type"></strong></div>
<div class="field">
<span>Public key</span>
<code id="wallet-pubkey" class="wallet-pubkey"></code>
</div>
<p id="wallet-verify" class="muted small"></p>
<div class="actions">
<button id="copy-wallet-btn" class="btn secondary" type="button">Копировать ключ</button>
</div>
</div>
<div id="status" class="status hidden"></div> <div id="status" class="status hidden"></div>
</section> </section>
</main> </main>
<script type="module" src="./popup.js"></script> <script type="module" src="./popup.js"></script>
</body> </body>
</html> </html>

View File

@ -2,12 +2,12 @@ import { formatPairingShortCode } from './js/lib/device-pairing.js';
const els = { const els = {
serverLoginInfo: document.querySelector('#server-login-info'), serverLoginInfo: document.querySelector('#server-login-info'),
serverAddress: document.querySelector('#server-address'),
loginInput: document.querySelector('#login-input'), loginInput: document.querySelector('#login-input'),
usePassword: document.querySelector('#use-password'), usePassword: document.querySelector('#use-password'),
passwordField: document.querySelector('#password-field'), passwordField: document.querySelector('#password-field'),
passwordInput: document.querySelector('#password-input'), passwordInput: document.querySelector('#password-input'),
startBtn: document.querySelector('#start-btn'), startBtn: document.querySelector('#start-btn'),
connectCard: document.querySelector('#connect-card'),
pairingCard: document.querySelector('#pairing-card'), pairingCard: document.querySelector('#pairing-card'),
shortCode: document.querySelector('#short-code'), shortCode: document.querySelector('#short-code'),
pairingHint: document.querySelector('#pairing-hint'), pairingHint: document.querySelector('#pairing-hint'),
@ -16,17 +16,22 @@ const els = {
status: document.querySelector('#status'), status: document.querySelector('#status'),
sessionCard: document.querySelector('#session-card'), sessionCard: document.querySelector('#session-card'),
sessionLogin: document.querySelector('#session-login'), 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'), resumeBtn: document.querySelector('#resume-btn'),
refreshDevicesBtn: document.querySelector('#refresh-devices-btn'), refreshDevicesBtn: document.querySelector('#refresh-devices-btn'),
disconnectBtn: document.querySelector('#disconnect-btn'), disconnectBtn: document.querySelector('#disconnect-btn'),
signingCard: document.querySelector('#signing-card'), walletCard: document.querySelector('#wallet-card'),
signKeySelect: document.querySelector('#sign-key-select'),
deviceSelect: document.querySelector('#device-select'), deviceSelect: document.querySelector('#device-select'),
homeserverList: document.querySelector('#homeserver-list'), homeserverList: document.querySelector('#homeserver-list'),
prepareSignBtn: document.querySelector('#prepare-sign-btn'), requestWalletBtn: document.querySelector('#request-wallet-btn'),
pendingApprovalCard: document.querySelector('#pending-approval-card'),
pendingApprovalSubtitle: document.querySelector('#pending-approval-subtitle'),
pendingApprovalDetails: document.querySelector('#pending-approval-details'),
cancelPendingApprovalBtn: document.querySelector('#cancel-pending-approval-btn'),
walletResultCard: document.querySelector('#wallet-result-card'),
walletType: document.querySelector('#wallet-type'),
walletPubkey: document.querySelector('#wallet-pubkey'),
walletVerify: document.querySelector('#wallet-verify'),
copyWalletBtn: document.querySelector('#copy-wallet-btn'),
connectionPill: document.querySelector('#connection-pill'), connectionPill: document.querySelector('#connection-pill'),
}; };
@ -34,16 +39,20 @@ let state = {
settings: { settings: {
serverLogin: 'shineupme', serverLogin: 'shineupme',
serverHttp: 'https://shineup.me', serverHttp: 'https://shineup.me',
serverUrl: 'wss://shineup.me/ws',
login: '', login: '',
}, },
pairing: { pairing: {
active: false, active: false,
pairingId: '',
expiresAtMs: 0, expiresAtMs: 0,
shortCode: '',
}, },
session: null, session: null,
connectionOnline: false, walletProfile: null,
signing: {
selectedDeviceName: '',
},
currentWallet: null,
pendingApproval: null,
status: { status: {
text: '', text: '',
kind: 'info', kind: 'info',
@ -60,7 +69,7 @@ function setStatus(message, kind = 'info') {
} }
function setConnectedPill(connected) { function setConnectedPill(connected) {
els.connectionPill.textContent = connected ? 'online' : 'offline'; els.connectionPill.textContent = connected ? 'подключено' : 'не подключено';
els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline'; els.connectionPill.className = connected ? 'pill pill-online' : 'pill pill-offline';
} }
@ -99,81 +108,119 @@ function renderHomeserverList(items = []) {
}); });
} }
function renderPendingApproval(pendingApproval) {
els.pendingApprovalDetails.innerHTML = '';
if (!pendingApproval) return;
const summary = pendingApproval.transactionSummary || {};
const programs = Array.isArray(summary.programs) && summary.programs.length
? summary.programs.join(', ')
: 'не определены';
const details = [
{ label: 'Сайт', value: pendingApproval.origin || '—', mono: true },
{ label: 'Кошелёк', value: pendingApproval.publicKeyBase58 || '—', mono: true },
{ label: 'Очередь', value: `${pendingApproval.queuePosition || 1} из ${pendingApproval.queueLength || 1}` },
{ label: 'Комментарий', value: pendingApproval.comment || 'Транзакция запрошена сайтом' },
{ label: 'Тип', value: summary.kind || 'legacy' },
{ label: 'Инструкций', value: String(summary.instructionCount ?? 0) },
{ label: 'Программы', value: programs, mono: true },
];
if (summary.feePayer) {
details.push({ label: 'Fee payer', value: summary.feePayer, mono: true });
}
if (summary.recentBlockhash) {
details.push({ label: 'Blockhash', value: summary.recentBlockhash, mono: true });
}
for (const item of details) {
const row = document.createElement('div');
row.className = 'detail-row';
const label = document.createElement('div');
label.className = 'detail-label';
label.textContent = item.label;
const value = document.createElement('div');
value.className = `detail-value${item.mono ? ' mono' : ''}`;
value.textContent = item.value;
row.append(label, value);
els.pendingApprovalDetails.append(row);
}
}
function applyState(nextState) { function applyState(nextState) {
state = nextState || state; state = nextState || state;
const loginValue = String(state?.settings?.login || ''); const loginValue = String(state?.settings?.login || '');
const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim(); const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim();
const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim(); const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim();
if (loginValue && resolvedServerLogin && resolvedServerAddress) { els.serverLoginInfo.textContent = resolvedServerLogin && resolvedServerAddress
els.serverLoginInfo.textContent = `Сервер SHiNE: ${resolvedServerLogin}`; ? `Сервер SHiNE: ${resolvedServerLogin} (${resolvedServerAddress})`
els.serverAddress.textContent = `Адрес: ${resolvedServerAddress}`; : 'Сервер SHiNE: —';
} else {
els.serverLoginInfo.textContent = 'Сервер SHiNE: —';
els.serverAddress.textContent = 'Адрес: —';
}
if (document.activeElement !== els.loginInput) { if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue; els.loginInput.value = loginValue;
} }
setConnectedPill(!!state?.connectionOnline);
setConnectedPill(!!state?.session);
setStatus(state?.status?.text || '', state?.status?.kind || 'info'); setStatus(state?.status?.text || '', state?.status?.kind || 'info');
const session = state?.session; const session = state?.session;
const walletProfile = state?.walletProfile; const walletProfile = state?.walletProfile;
const signing = state?.signing || {}; const signing = state?.signing || {};
if (session) { const currentWallet = state?.currentWallet || null;
els.sessionCard.classList.remove('hidden'); const pendingApproval = state?.pendingApproval || null;
els.sessionLogin.textContent = session.login || '—';
els.sessionId.textContent = session.sessionId || '—';
els.sessionType.textContent = String(session.sessionType || 50) === '50' ? 'wallet' : String(session.sessionType || '—');
els.deviceKeyShort.textContent = shortKey(walletProfile?.publicKeys?.deviceKeyBase58 || '');
els.signingCard.classList.remove('hidden');
} else {
els.sessionCard.classList.add('hidden');
els.sessionLogin.textContent = '—';
els.sessionId.textContent = '—';
els.sessionType.textContent = 'wallet';
els.deviceKeyShort.textContent = '—';
els.signingCard.classList.add('hidden');
}
const signKeyOptions = Array.isArray(walletProfile?.signingKeyOptions) ? walletProfile.signingKeyOptions : []; els.connectCard.classList.toggle('hidden', !!session);
els.signKeySelect.innerHTML = ''; els.sessionCard.classList.toggle('hidden', !session);
signKeyOptions.forEach((item) => { els.walletCard.classList.toggle('hidden', !session);
const option = document.createElement('option'); els.pendingApprovalCard.classList.toggle('hidden', !pendingApproval);
option.value = item.id;
option.textContent = item.label; if (session) {
option.selected = item.id === signing.selectedKeyId; els.sessionLogin.textContent = session.login || '—';
els.signKeySelect.append(option); }
});
const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : []; const homeservers = Array.isArray(walletProfile?.homeserverSessions) ? walletProfile.homeserverSessions : [];
els.deviceSelect.innerHTML = ''; els.deviceSelect.innerHTML = '';
homeservers.forEach((item) => { homeservers.forEach((item) => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = item.sessionName; option.value = item.sessionName;
option.textContent = `${item.sessionName} [${item.onlineState || 'unknown'}]`; option.textContent = `${item.sessionName} [${item.onlineState || 'offline'}]`;
option.selected = item.sessionName === signing.selectedDeviceName; option.selected = item.sessionName === signing.selectedDeviceName;
els.deviceSelect.append(option); els.deviceSelect.append(option);
}); });
renderHomeserverList(homeservers); renderHomeserverList(homeservers);
els.prepareSignBtn.disabled = !session || !signing.selectedKeyId || !signing.selectedDeviceName; els.requestWalletBtn.disabled = !session || !signing.selectedDeviceName;
if (pendingApproval) {
const queueSuffix = (pendingApproval.queueLength || 1) > 1
? ` В очереди ${pendingApproval.queueLength} транзакции.`
: '';
els.pendingApprovalSubtitle.textContent = pendingApproval.origin
? `Сайт ${pendingApproval.origin} запросил подписание транзакции.${queueSuffix}`
: `Сайт запросил подписание транзакции.${queueSuffix}`;
renderPendingApproval(pendingApproval);
} else {
els.pendingApprovalSubtitle.textContent = 'Сайт запросил подписание транзакции.';
els.pendingApprovalDetails.innerHTML = '';
}
if (currentWallet?.publicKeyBase58) {
els.walletResultCard.classList.remove('hidden');
els.walletType.textContent = currentWallet.type || '—';
els.walletPubkey.textContent = currentWallet.publicKeyBase58 || '—';
els.walletVerify.textContent = currentWallet.verificationText || '—';
} else {
els.walletResultCard.classList.add('hidden');
els.walletType.textContent = '—';
els.walletPubkey.textContent = '—';
els.walletVerify.textContent = '—';
}
const pairing = state?.pairing || {}; const pairing = state?.pairing || {};
if (pairing.active) { if (pairing.active) {
els.pairingCard.classList.remove('hidden'); els.pairingCard.classList.remove('hidden');
const shortCode = String(pairing.shortCode || els.shortCode.dataset.shortCode || els.shortCode.textContent || ''); els.shortCode.textContent = formatPairingShortCode(String(pairing.shortCode || ''));
els.shortCode.dataset.shortCode = shortCode;
els.shortCode.textContent = formatPairingShortCode(shortCode);
els.pairingHint.textContent = pairing.trustedSessionOnline
? 'Покажите код на доверенном устройстве и подтвердите выпуск wallet-session.'
: 'Сейчас нет онлайн доверенной сессии. Откройте другое устройство и подтвердите заявку.';
const leftMs = Number(pairing.expiresAtMs || 0) - Date.now(); const leftMs = Number(pairing.expiresAtMs || 0) - Date.now();
els.pairingExpire.textContent = leftMs > 0 ? `Код действителен ещё ${formatRemaining(leftMs)}.` : 'Время ожидания истекло.'; els.pairingExpire.textContent = leftMs > 0 ? `Код действителен ещё ${formatRemaining(leftMs)}.` : 'Время ожидания истекло.';
els.startBtn.disabled = true; els.startBtn.disabled = true;
} else { } else {
els.pairingCard.classList.add('hidden'); els.pairingCard.classList.add('hidden');
els.shortCode.textContent = formatPairingShortCode(''); els.shortCode.textContent = formatPairingShortCode('');
delete els.shortCode.dataset.shortCode;
els.pairingExpire.textContent = ''; els.pairingExpire.textContent = '';
els.startBtn.disabled = false; els.startBtn.disabled = false;
} }
@ -241,16 +288,13 @@ async function startPairing() {
return; return;
} }
setStatus('Создаём wallet-session заявку...', 'info'); setStatus('Создаём wallet-session заявку...', 'info');
els.startBtn.disabled = true;
try { try {
const response = await sendMessage('wallet:startPairing', { await sendMessage('wallet:startPairing', {
login, login,
usePassword: !!els.usePassword.checked, usePassword: !!els.usePassword.checked,
password: String(els.passwordInput.value || ''), password: String(els.passwordInput.value || ''),
}); });
applyState(response.state);
} catch (error) { } catch (error) {
els.startBtn.disabled = false;
setStatus(error.message || 'Не удалось начать pairing.', 'error'); setStatus(error.message || 'Не удалось начать pairing.', 'error');
} }
} }
@ -289,23 +333,41 @@ async function refreshDevices() {
} }
} }
async function updateSigningSelection() { async function updateDeviceSelection() {
try { try {
await sendMessage('wallet:updateSigningSelection', { await sendMessage('wallet:updateSigningSelection', {
selectedKeyId: String(els.signKeySelect.value || '').trim(),
selectedDeviceName: String(els.deviceSelect.value || '').trim(), selectedDeviceName: String(els.deviceSelect.value || '').trim(),
}); });
} catch (error) { } catch (error) {
setStatus(error.message || 'Не удалось обновить выбор для подписи.', 'error'); setStatus(error.message || 'Не удалось обновить выбор homeserver.', 'error');
} }
} }
async function prepareSignSignal() { async function requestCurrentWallet() {
setStatus('Готовим каркас запроса подписи...', 'info'); setStatus('Запрашиваем текущий кошелёк с ESP32...', 'info');
try { try {
await sendMessage('wallet:prepareSignSignal'); await sendMessage('wallet:requestCurrentWallet');
} catch (error) { } catch (error) {
setStatus(error.message || 'Не удалось подготовить запрос подписи.', 'error'); setStatus(error.message || 'Не удалось получить кошелёк с ESP32.', 'error');
}
}
async function copyWalletKey() {
const value = String(els.walletPubkey.textContent || '').trim();
if (!value || value === '—') return;
try {
await navigator.clipboard.writeText(value);
setStatus('Публичный ключ скопирован.', 'info');
} catch (error) {
setStatus(error.message || 'Не удалось скопировать ключ.', 'error');
}
}
async function cancelPendingApproval() {
try {
await sendMessage('wallet:cancelPendingSiteApproval');
} catch (error) {
setStatus(error.message || 'Не удалось отменить ожидание подписи.', 'error');
} }
} }
@ -340,9 +402,10 @@ function bindUi() {
els.resumeBtn.addEventListener('click', () => { void resumeSession(); }); els.resumeBtn.addEventListener('click', () => { void resumeSession(); });
els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); }); els.refreshDevicesBtn.addEventListener('click', () => { void refreshDevices(); });
els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); }); els.disconnectBtn.addEventListener('click', () => { void disconnectSession(); });
els.signKeySelect.addEventListener('change', () => { void updateSigningSelection(); }); els.deviceSelect.addEventListener('change', () => { void updateDeviceSelection(); });
els.deviceSelect.addEventListener('change', () => { void updateSigningSelection(); }); els.requestWalletBtn.addEventListener('click', () => { void requestCurrentWallet(); });
els.prepareSignBtn.addEventListener('click', () => { void prepareSignSignal(); }); els.copyWalletBtn.addEventListener('click', () => { void copyWalletKey(); });
els.cancelPendingApprovalBtn.addEventListener('click', () => { void cancelPendingApproval(); });
} }
async function init() { async function init() {

View File

@ -0,0 +1,433 @@
import { PublicKey, Transaction, VersionedTransaction } from './js/lib/vendor/solana-publickey-bundle.js';
const PAGE_REQUEST = 'shine-wallet-page-request';
const PAGE_RESPONSE = 'shine-wallet-page-response';
const PAGE_MESSAGE_TARGET_ORIGIN = '*';
const STANDARD_REGISTER_EVENT = 'wallet-standard:register-wallet';
const STANDARD_APP_READY_EVENT = 'wallet-standard:app-ready';
const SOLANA_CHAINS = ['solana:mainnet', 'solana:devnet', 'solana:testnet'];
const SOLANA_STANDARD_FEATURES = ['solana:signTransaction'];
const WALLET_ICON = `data:image/svg+xml;base64,${btoa(
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none"><rect width="96" height="96" rx="20" fill="#101722"/><path d="M23 28h50l-8 10H31l-8-10Z" fill="#3FB0FF"/><path d="M31 44h34l8 10H23l8-10Z" fill="#77D67A"/><path d="M23 68h50l-8-10H31l-8 10Z" fill="#A881FF"/></svg>'
)}`;
function bytesToBase64(bytes) {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode(...slice);
}
return btoa(binary);
}
function base64ToBytes(value) {
const binary = atob(String(value || '').trim());
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
function createProviderError(message, code = '') {
const error = new Error(String(message || 'Wallet provider error'));
if (code === 'USER_REJECTED' || code === 'NOT_TRUSTED') {
error.code = 4001;
} else if (code) {
error.code = code;
}
return error;
}
function summarizeTransaction(transaction) {
const summary = {
kind: 'legacy',
instructionCount: 0,
accountCount: 0,
feePayer: '',
recentBlockhash: '',
programs: [],
};
if (!transaction) return summary;
const isVersioned = typeof transaction?.version === 'number' || transaction instanceof VersionedTransaction;
summary.kind = isVersioned ? `versioned:${String(transaction.version)}` : 'legacy';
summary.feePayer = String(transaction?.feePayer?.toBase58?.() || '').trim();
summary.recentBlockhash = String(transaction?.recentBlockhash || transaction?.message?.recentBlockhash || '').trim();
if (isVersioned) {
const message = transaction?.message || {};
const staticKeys = Array.isArray(message?.staticAccountKeys) ? message.staticAccountKeys : [];
const instructions = Array.isArray(message?.compiledInstructions) ? message.compiledInstructions : [];
summary.instructionCount = instructions.length;
summary.accountCount = staticKeys.length;
summary.programs = instructions
.map((instruction) => staticKeys[instruction?.programIdIndex]?.toBase58?.() || '')
.filter(Boolean)
.slice(0, 5);
return summary;
}
const instructions = Array.isArray(transaction?.instructions) ? transaction.instructions : [];
summary.instructionCount = instructions.length;
summary.accountCount = Array.isArray(transaction?.signatures) ? transaction.signatures.length : 0;
summary.programs = instructions
.map((instruction) => instruction?.programId?.toBase58?.() || '')
.filter(Boolean)
.slice(0, 5);
return summary;
}
function createRequest(method, params = {}) {
const id = `shine-wallet-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
return new Promise((resolve, reject) => {
const onMessage = (event) => {
if (event.source !== window) return;
const data = event.data || {};
if (data?.target !== PAGE_RESPONSE || String(data?.id || '') !== id) return;
window.removeEventListener('message', onMessage);
if (!data?.ok) {
reject(createProviderError(data?.error || 'Wallet request failed', String(data?.code || '')));
return;
}
resolve(data?.result || {});
};
window.addEventListener('message', onMessage);
window.postMessage({
target: PAGE_REQUEST,
id,
method,
params,
}, PAGE_MESSAGE_TARGET_ORIGIN);
});
}
function serializeTransactionBase64(transaction) {
if (!transaction || typeof transaction.serialize !== 'function') {
throw createProviderError('Unsupported transaction object', 'UNSUPPORTED_TRANSACTION');
}
let raw;
try {
raw = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
} catch {
raw = transaction.serialize();
}
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
return bytesToBase64(bytes);
}
function deserializeSignedTransaction(base64, originalTransaction) {
const bytes = base64ToBytes(base64);
const ctor = originalTransaction?.constructor;
if (ctor && typeof ctor.deserialize === 'function') {
return ctor.deserialize(bytes);
}
if (ctor && typeof ctor.from === 'function') {
return ctor.from(bytes);
}
if (typeof originalTransaction?.version === 'number') {
return VersionedTransaction.deserialize(bytes);
}
return Transaction.from(bytes);
}
class ShineWalletAccount {
constructor(publicKeyBase58) {
this.address = publicKeyBase58;
this.publicKey = new Uint8Array(new PublicKey(publicKeyBase58).toBytes());
this.chains = SOLANA_CHAINS.slice();
this.features = SOLANA_STANDARD_FEATURES.slice();
this.label = 'SHiNE Wallet';
this.icon = WALLET_ICON;
}
}
class ShineProviderCore {
constructor() {
this.publicKey = null;
this.isConnected = false;
this._legacyListeners = new Map();
this._standardListeners = new Set();
this._accounts = [];
}
get publicKeyBase58() {
return this.publicKey?.toBase58?.() || '';
}
get standardAccounts() {
return this._accounts.slice();
}
async connect(options = {}) {
const onlyIfTrusted = !!options?.onlyIfTrusted || !!options?.silent;
const result = await createRequest('connect', { onlyIfTrusted });
const nextKey = new PublicKey(String(result?.publicKeyBase58 || '').trim());
this.publicKey = nextKey;
this.isConnected = true;
this._accounts = [new ShineWalletAccount(nextKey.toBase58())];
this.emitLegacy('connect', nextKey);
this.emitLegacy('accountChanged', nextKey);
this.emitStandardChange();
return {
publicKey: nextKey,
accounts: this.standardAccounts,
};
}
async disconnect() {
await createRequest('disconnect', {});
this.isConnected = false;
this.publicKey = null;
this._accounts = [];
this.emitLegacy('disconnect');
this.emitLegacy('accountChanged', null);
this.emitStandardChange();
}
async signTransaction(transaction, comment = '') {
if (!this.publicKey) {
await this.connect();
}
const transactionBase64 = serializeTransactionBase64(transaction);
const transactionSummary = summarizeTransaction(transaction);
const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKeyBase58,
transactionBase64,
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
transactionSummary,
});
return deserializeSignedTransaction(String(result?.signedTransactionBase64 || ''), transaction);
}
async signTransactionBytes(transactionBytes, comment = '') {
if (!this.publicKey) {
await this.connect();
}
const transactionSummary = {
kind: 'raw-bytes',
instructionCount: 0,
accountCount: 0,
feePayer: this.publicKeyBase58,
recentBlockhash: '',
programs: [],
byteLength: Number(transactionBytes?.length || 0),
};
const result = await createRequest('signTransaction', {
publicKeyBase58: this.publicKeyBase58,
transactionBase64: bytesToBase64(transactionBytes),
comment: String(comment || '').trim() || `Site ${window.location.origin} requested transaction signature`,
transactionSummary,
});
return base64ToBytes(String(result?.signedTransactionBase64 || '').trim());
}
onLegacy(event, handler) {
const key = String(event || '');
if (!this._legacyListeners.has(key)) {
this._legacyListeners.set(key, new Set());
}
this._legacyListeners.get(key).add(handler);
return this;
}
offLegacy(event, handler) {
const key = String(event || '');
const bucket = this._legacyListeners.get(key);
if (!bucket) return this;
bucket.delete(handler);
if (!bucket.size) this._legacyListeners.delete(key);
return this;
}
emitLegacy(event, payload) {
const bucket = this._legacyListeners.get(String(event || ''));
if (!bucket?.size) return;
for (const handler of [...bucket]) {
try {
handler(payload);
} catch {}
}
}
onStandardChange(listener) {
this._standardListeners.add(listener);
return () => {
this._standardListeners.delete(listener);
};
}
emitStandardChange() {
const properties = { accounts: this.standardAccounts };
for (const listener of [...this._standardListeners]) {
try {
listener(properties);
} catch {}
}
}
}
class ShineSolanaProvider {
constructor(core) {
this.core = core;
this.isSHiNE = true;
this.isPhantom = true;
}
get publicKey() {
return this.core.publicKey;
}
get isConnected() {
return this.core.isConnected;
}
on(event, handler) {
return this.core.onLegacy(event, handler);
}
off(event, handler) {
return this.core.offLegacy(event, handler);
}
removeListener(event, handler) {
return this.off(event, handler);
}
async connect(options = {}) {
const result = await this.core.connect(options);
return { publicKey: result.publicKey };
}
async disconnect() {
await this.core.disconnect();
}
async signTransaction(transaction) {
return this.core.signTransaction(transaction);
}
async signAllTransactions(transactions = []) {
const list = Array.isArray(transactions) ? transactions : [];
const outputs = [];
for (const transaction of list) {
outputs.push(await this.core.signTransaction(transaction));
}
return outputs;
}
async request(args = {}) {
const method = String(args?.method || '');
const params = args?.params;
if (method === 'connect') {
return this.connect(Array.isArray(params) ? params[0] : params || {});
}
if (method === 'disconnect') {
return this.disconnect();
}
if (method === 'signTransaction') {
const tx = Array.isArray(params) ? params[0] : params?.transaction || params;
return this.signTransaction(tx);
}
if (method === 'signAllTransactions') {
const transactions = Array.isArray(params)
? params
: Array.isArray(params?.transactions) ? params.transactions : [];
return this.signAllTransactions(transactions);
}
throw createProviderError(`Unsupported request method: ${method}`, 'UNSUPPORTED_METHOD');
}
}
class ShineStandardWallet {
constructor(core) {
this.core = core;
this.version = '1.0.0';
this.name = 'SHiNE Wallet';
this.icon = WALLET_ICON;
this.chains = SOLANA_CHAINS.slice();
this.features = {
'standard:connect': {
version: '1.0.0',
connect: async (input = {}) => {
const result = await this.core.connect({ silent: !!input?.silent });
return { accounts: result.accounts };
},
},
'standard:disconnect': {
version: '1.0.0',
disconnect: async () => {
await this.core.disconnect();
},
},
'standard:events': {
version: '1.0.0',
on: (event, listener) => {
if (event !== 'change' || typeof listener !== 'function') {
return () => {};
}
return this.core.onStandardChange(listener);
},
},
'solana:signTransaction': {
version: '1.0.0',
supportedTransactionVersions: ['legacy', 0],
signTransaction: async (...inputs) => {
const outputs = [];
for (const input of inputs) {
const accountAddress = String(input?.account?.address || '').trim();
if (accountAddress && this.core.publicKeyBase58 && accountAddress !== this.core.publicKeyBase58) {
throw createProviderError('Requested account does not match current wallet account', 'ACCOUNT_MISMATCH');
}
const comment = `Site ${window.location.origin} requested transaction signature`;
const signedTransaction = await this.core.signTransactionBytes(new Uint8Array(input.transaction), comment);
outputs.push({ signedTransaction });
}
return outputs;
},
},
};
}
get accounts() {
return this.core.standardAccounts;
}
}
function registerStandardWallet(wallet) {
const callback = ({ register }) => register(wallet);
try {
window.dispatchEvent(new CustomEvent(STANDARD_REGISTER_EVENT, { detail: callback }));
} catch (error) {
console.error('wallet-standard register dispatch failed', error);
}
try {
window.addEventListener(STANDARD_APP_READY_EVENT, ({ detail }) => {
try {
callback(detail);
} catch (error) {
console.error('wallet-standard app-ready callback failed', error);
}
});
} catch (error) {
console.error('wallet-standard app-ready listener failed', error);
}
try {
window.navigator.wallets = window.navigator.wallets || [];
window.navigator.wallets.push(callback);
} catch {}
}
const core = new ShineProviderCore();
const legacyProvider = new ShineSolanaProvider(core);
const standardWallet = new ShineStandardWallet(core);
registerStandardWallet(standardWallet);
if (!window.solana) {
window.solana = legacyProvider;
window.phantom = window.phantom || {};
window.phantom.solana = legacyProvider;
window.dispatchEvent(new Event('solana#initialized'));
}

View File

@ -42,7 +42,7 @@ shine-UI/server-ui.html
Для обновления — только root + device (blockchain-ключ не нужен). Для обновления — только root + device (blockchain-ключ не нужен).
Актуальные адреса программ Solana (devnet): Актуальные адреса программ Solana (devnet):
- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm` - `shine_users`: `3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ`
- `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW` - `shine_payments`: `c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW`
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md` Подробнее: `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"; public static final String SOLANA_RPC_URL = "https://api.devnet.solana.com";
// Программа регистрации пользователей (shine_users), задеплоена в devnet. // Программа регистрации пользователей (shine_users), задеплоена в devnet.
public static final String SHINE_USERS_PROGRAM_ID = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; public static final String SHINE_USERS_PROGRAM_ID = "3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ";
// Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково. // Отдельно фиксируем адреса связанной инфраструктуры, чтобы UI/сервер ссылались одинаково.
public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; public static final String SHINE_LOGIN_GUARD_PROGRAM_ID = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
public static final String SHINE_PAYMENTS_PROGRAM_ID = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; 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 // 1. solana_users
// ВАЖНО: // ВАЖНО:
// - Все требуемые поля теперь лежат в 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. // - Поиск по login в DAO сделан case-insensitive.
// - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY. // - Для защиты от дублей "Anya" и "anya" добавляем COLLATE NOCASE на PRIMARY KEY.
st.executeUpdate(""" st.executeUpdate("""
@ -149,7 +149,7 @@ public final class DatabaseInitializer {
blockchain_name TEXT NOT NULL, blockchain_name TEXT NOT NULL,
solana_key TEXT NOT NULL, solana_key TEXT NOT NULL,
blockchain_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, param TEXT NOT NULL,
time_ms INTEGER NOT NULL, time_ms INTEGER NOT NULL,
value TEXT NOT NULL, value TEXT NOT NULL,
device_key TEXT, client_key TEXT,
signature TEXT, signature TEXT,
FOREIGN KEY (login) REFERENCES solana_users(login), FOREIGN KEY (login) REFERENCES solana_users(login),
UNIQUE (login, param) UNIQUE (login, param)

View File

@ -17,7 +17,7 @@ import java.util.List;
* - blockchain_name TEXT NOT NULL * - blockchain_name TEXT NOT NULL
* - solana_key TEXT NOT NULL * - solana_key TEXT NOT NULL
* - blockchain_key TEXT NOT NULL * - blockchain_key TEXT NOT NULL
* - device_key TEXT NOT NULL * - client_key TEXT NOT NULL
* *
* Правило работы с соединениями: * Правило работы с соединениями:
* - методы с Connection НЕ закрывают соединение * - методы с Connection НЕ закрывают соединение
@ -45,7 +45,7 @@ public final class SolanaUsersDAO {
public void insert(Connection c, SolanaUserEntry user) throws SQLException { public void insert(Connection c, SolanaUserEntry user) throws SQLException {
String sql = """ String sql = """
INSERT INTO solana_users ( INSERT INTO solana_users (
login, blockchain_name, solana_key, blockchain_key, device_key login, blockchain_name, solana_key, blockchain_key, client_key
) VALUES (?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?)
"""; """;
@ -54,7 +54,7 @@ public final class SolanaUsersDAO {
ps.setString(2, user.getBlockchainName()); ps.setString(2, user.getBlockchainName());
ps.setString(3, user.getSolanaKey()); ps.setString(3, user.getSolanaKey());
ps.setString(4, user.getBlockchainKey()); ps.setString(4, user.getBlockchainKey());
ps.setString(5, user.getDeviceKey()); ps.setString(5, user.getClientKey());
ps.executeUpdate(); ps.executeUpdate();
} }
} }
@ -126,7 +126,7 @@ public final class SolanaUsersDAO {
blockchain_name, blockchain_name,
solana_key, solana_key,
blockchain_key, blockchain_key,
device_key client_key
FROM solana_users FROM solana_users
WHERE LOWER(login) = LOWER(?) WHERE LOWER(login) = LOWER(?)
"""; """;
@ -155,7 +155,7 @@ public final class SolanaUsersDAO {
blockchain_name, blockchain_name,
solana_key, solana_key,
blockchain_key, blockchain_key,
device_key client_key
FROM solana_users FROM solana_users
WHERE blockchain_name = ? WHERE blockchain_name = ?
"""; """;
@ -184,7 +184,7 @@ public final class SolanaUsersDAO {
blockchain_name, blockchain_name,
solana_key, solana_key,
blockchain_key, blockchain_key,
device_key client_key
FROM solana_users FROM solana_users
WHERE LOWER(login) LIKE ? WHERE LOWER(login) LIKE ?
ORDER BY login ORDER BY login
@ -219,7 +219,7 @@ public final class SolanaUsersDAO {
e.setBlockchainName(rs.getString("blockchain_name")); e.setBlockchainName(rs.getString("blockchain_name"));
e.setSolanaKey(rs.getString("solana_key")); e.setSolanaKey(rs.getString("solana_key"));
e.setBlockchainKey(rs.getString("blockchain_key")); e.setBlockchainKey(rs.getString("blockchain_key"));
e.setDeviceKey(rs.getString("device_key")); e.setClientKey(rs.getString("client_key"));
return e; return e;
} }

View File

@ -7,7 +7,7 @@ import java.sql.*;
/** /**
* UserCreateDAO атомарное добавление пользователя: * 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 ...) * - blockchain_state (blockchain_name, login, blockchain_key, size_limit, ... last_block_number=-1 ...)
* *
* ВАЖНО: * ВАЖНО:
@ -39,7 +39,7 @@ public final class UserCreateDAO {
String blockchainName, String blockchainName,
String solanaKey, String solanaKey,
String blockchainKey, String blockchainKey,
String deviceKey, String clientKey,
long sizeLimit, long sizeLimit,
long nowMs long nowMs
) throws SQLException { ) throws SQLException {
@ -55,7 +55,7 @@ public final class UserCreateDAO {
u.setBlockchainName(blockchainName); u.setBlockchainName(blockchainName);
u.setSolanaKey(solanaKey); u.setSolanaKey(solanaKey);
u.setBlockchainKey(blockchainKey); u.setBlockchainKey(blockchainKey);
u.setDeviceKey(deviceKey); u.setClientKey(clientKey);
usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint usersDao.insert(c, u); // если login занят (NOCASE) или blockchainName (unique) -> constraint

View File

@ -43,14 +43,14 @@ public final class UserParamsDAO {
param, param,
time_ms, time_ms,
value, value,
device_key, client_key,
signature signature
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(login, param) ON CONFLICT(login, param)
DO UPDATE SET DO UPDATE SET
time_ms = excluded.time_ms, time_ms = excluded.time_ms,
value = excluded.value, value = excluded.value,
device_key = excluded.device_key, client_key = excluded.client_key,
signature = excluded.signature signature = excluded.signature
WHERE users_params.time_ms < excluded.time_ms WHERE users_params.time_ms < excluded.time_ms
"""; """;
@ -61,7 +61,7 @@ public final class UserParamsDAO {
ps.setLong(3, e.getTimeMs()); ps.setLong(3, e.getTimeMs());
ps.setString(4, e.getValue()); 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); else ps.setNull(5, Types.VARCHAR);
if (e.getSignature() != null) ps.setString(6, e.getSignature()); if (e.getSignature() != null) ps.setString(6, e.getSignature());
@ -86,7 +86,7 @@ public final class UserParamsDAO {
param, param,
time_ms, time_ms,
value, value,
device_key, client_key,
signature signature
FROM users_params FROM users_params
WHERE login = ? COLLATE NOCASE AND param = ? WHERE login = ? COLLATE NOCASE AND param = ?
@ -117,7 +117,7 @@ public final class UserParamsDAO {
param, param,
time_ms, time_ms,
value, value,
device_key, client_key,
signature signature
FROM users_params FROM users_params
WHERE login = ? COLLATE NOCASE WHERE login = ? COLLATE NOCASE
@ -149,9 +149,9 @@ public final class UserParamsDAO {
e.setTimeMs(rs.getLong("time_ms")); e.setTimeMs(rs.getLong("time_ms"));
e.setValue(rs.getString("value")); e.setValue(rs.getString("value"));
String dk = rs.getString("device_key"); String dk = rs.getString("client_key");
if (rs.wasNull()) dk = null; if (rs.wasNull()) dk = null;
e.setDeviceKey(dk); e.setClientKey(dk);
String sig = rs.getString("signature"); String sig = rs.getString("signature");
if (rs.wasNull()) sig = null; if (rs.wasNull()) sig = null;

View File

@ -12,7 +12,7 @@ import java.util.Base64;
* - blockchain_name TEXT NOT NULL * - blockchain_name TEXT NOT NULL
* - solana_key TEXT NOT NULL * - solana_key TEXT NOT NULL
* - blockchain_key TEXT NOT NULL * - blockchain_key TEXT NOT NULL
* - device_key TEXT NOT NULL * - client_key TEXT NOT NULL
*/ */
public class SolanaUserEntry { public class SolanaUserEntry {
@ -27,7 +27,7 @@ public class SolanaUserEntry {
private String blockchainKey; private String blockchainKey;
/** Ключ устройства (публичный ключ устройства) */ /** Ключ устройства (публичный ключ устройства) */
private String deviceKey; private String clientKey;
public SolanaUserEntry() {} public SolanaUserEntry() {}
@ -35,12 +35,12 @@ public class SolanaUserEntry {
String blockchainName, String blockchainName,
String solanaKey, String solanaKey,
String blockchainKey, String blockchainKey,
String deviceKey) { String clientKey) {
this.login = login; this.login = login;
this.blockchainName = blockchainName; this.blockchainName = blockchainName;
this.solanaKey = solanaKey; this.solanaKey = solanaKey;
this.blockchainKey = blockchainKey; this.blockchainKey = blockchainKey;
this.deviceKey = deviceKey; this.clientKey = clientKey;
} }
public String getLogin() { return login; } public String getLogin() { return login; }
@ -55,13 +55,13 @@ public class SolanaUserEntry {
public String getBlockchainKey() { return blockchainKey; } public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
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; }
// оставляю этот метод как утилиту (иногда удобно), но он работает только для deviceKey: // оставляю этот метод как утилиту (иногда удобно), но он работает только для clientKey:
public byte[] getDeviceKeyByte() { public byte[] getClientKeyByte() {
if (deviceKey == null) return null; if (clientKey == null) return null;
String s = deviceKey.trim(); String s = clientKey.trim();
if (s.isEmpty()) return null; if (s.isEmpty()) return null;
try { try {

View File

@ -8,7 +8,7 @@ package shine.db.entities;
* - param TEXT NOT NULL * - param TEXT NOT NULL
* - time_ms INTEGER NOT NULL * - time_ms INTEGER NOT NULL
* - value TEXT NOT NULL * - value TEXT NOT NULL
* - device_key TEXT NULL * - client_key TEXT NULL
* - signature TEXT NULL * - signature TEXT NULL
*/ */
public class UserParamEntry { public class UserParamEntry {
@ -18,17 +18,17 @@ public class UserParamEntry {
private long timeMs; private long timeMs;
private String value; private String value;
private String deviceKey; private String clientKey;
private String signature; private String signature;
public UserParamEntry() {} 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.login = login;
this.param = param; this.param = param;
this.timeMs = timeMs; this.timeMs = timeMs;
this.value = value; this.value = value;
this.deviceKey = deviceKey; this.clientKey = clientKey;
this.signature = signature; this.signature = signature;
} }
@ -44,8 +44,8 @@ public class UserParamEntry {
public String getValue() { return value; } public String getValue() { return value; }
public void setValue(String value) { this.value = value; } public void setValue(String value) { this.value = value; }
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 getSignature() { return signature; } public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = 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): * Важно (v2):
* - Авторизация всегда 2 шага: * - Авторизация всегда 2 шага:
* A) Создание новой сессии через deviceKey: * A) Создание новой сессии через clientKey:
* AuthChallenge(login) -> ctx.authNonce * AuthChallenge(login) -> ctx.authNonce
* CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession * CreateAuthSession(...) -> ctx.AUTH_STATUS_USER + ctx.activeSession
* *
@ -39,7 +39,7 @@ public class ConnectionContext {
/** /**
* Одноразовый nonce, выданный на шаге 1 (AuthChallenge), * Одноразовый nonce, выданный на шаге 1 (AuthChallenge),
* используется на шаге CreateAuthSession для проверки подписи deviceKey. * используется на шаге CreateAuthSession для проверки подписи clientKey.
*/ */
private String authNonce; 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 создания новой сессии. * AuthChallenge (v2) шаг 1 создания новой сессии.
* *
* Логика авторизации (v2): * Логика авторизации (v2):
* - Создание новой сессии возможно ТОЛЬКО через deviceKey пользователя. * - Создание новой сессии возможно ТОЛЬКО через clientKey пользователя.
* - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге: * - Этот handler выдаёт одноразовый authNonce, который клиент использует во втором шаге:
* CreateAuthSession(..., signature(deviceKey, AUTH_CREATE_SESSION:...)) * CreateAuthSession(..., signature(clientKey, AUTH_CREATE_SESSION:...))
* *
* Что делает: * Что делает:
* 1) Проверяет login. * 1) Проверяет login.

View File

@ -30,7 +30,7 @@ import java.security.SecureRandom;
import java.sql.SQLException; import java.sql.SQLException;
/** /**
* CreateAuthSession (v2) шаг 2 создания новой сессии (ТОЛЬКО deviceKey). * CreateAuthSession (v2) шаг 2 создания новой сессии (ТОЛЬКО clientKey).
* *
* Логика авторизации (v2): * Логика авторизации (v2):
* - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...) * - Создание сессии: AuthChallenge(login) -> authNonce -> CreateAuthSession(...)
@ -38,7 +38,7 @@ import java.sql.SQLException;
* отправляет на сервер sessionKey целиком одной строкой. * отправляет на сервер sessionKey целиком одной строкой.
* - Сервер сохраняет sessionKey в active_sessions.session_key как есть. * - Сервер сохраняет sessionKey в active_sessions.session_key как есть.
* *
* Подпись deviceKey (Ed25519) проверяется над строкой (UTF-8): * Подпись clientKey (Ed25519) проверяется над строкой (UTF-8):
* AUTH_CREATE_SESSION:{login}:{sessionKey}:{storagePwd}:{timeMs}:{authNonce} * 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 clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getClientPlatform());
String deviceKeyFromDb = user.getDeviceKey(); String clientKeyFromDb = user.getClientKey();
if (deviceKeyFromDb == null || deviceKeyFromDb.isBlank()) { if (clientKeyFromDb == null || clientKeyFromDb.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"NO_DEVICE_KEY", "NO_DEVICE_KEY",
"Отсутствует deviceKey у пользователя" "Отсутствует clientKey у пользователя"
); );
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no deviceKey"); closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: no clientKey");
return err; return err;
} }
@ -261,28 +261,28 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
return err; return err;
} }
String deviceKeyFromReq = req.getDeviceKey(); String clientKeyFromReq = req.getClientKey();
if (deviceKeyFromReq == null || deviceKeyFromReq.isBlank()) { if (clientKeyFromReq == null || clientKeyFromReq.isBlank()) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.BAD_REQUEST, WireCodes.Status.BAD_REQUEST,
"EMPTY_DEVICE_KEY", "EMPTY_DEVICE_KEY",
"Пустой deviceKey" "Пустой clientKey"
); );
closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty deviceKey"); closeConnectionAfterErrorResponse(ctx, 4001, "Auth failed: empty clientKey");
return err; return err;
} }
deviceKeyFromReq = deviceKeyFromReq.trim(); clientKeyFromReq = clientKeyFromReq.trim();
// TODO: для ротации device_key стоит дополнительно сверять актуальное значение через Solana. // TODO: для ротации client_key стоит дополнительно сверять актуальное значение через Solana.
if (!deviceKeyFromReq.equals(deviceKeyFromDb)) { if (!clientKeyFromReq.equals(clientKeyFromDb)) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
req, req,
WireCodes.Status.UNVERIFIED, WireCodes.Status.UNVERIFIED,
"DEVICE_KEY_NOT_ACTUAL", "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; return err;
} }
@ -294,7 +294,7 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
storagePwd, storagePwd,
authNonce, authNonce,
timeMs, timeMs,
deviceKeyFromDb, clientKeyFromDb,
signatureB64 signatureB64
); );
} catch (UnsupportedOperationException ex) { } catch (UnsupportedOperationException ex) {
@ -302,9 +302,9 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
req, req,
422, 422,
"UNSUPPORTED_KEY_ALGORITHM", "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; return err;
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
Net_Response err = NetExceptionResponseFactory.error( Net_Response err = NetExceptionResponseFactory.error(
@ -440,11 +440,11 @@ public class Net_CreateAuthSession__Handler implements JsonMessageHandler {
String storagePwd, String storagePwd,
String authNonce, String authNonce,
long timeMs, long timeMs,
String deviceKey, String clientKey,
String signatureB64 String signatureB64
) throws IllegalArgumentException { ) throws IllegalArgumentException {
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(deviceKey, "deviceKey"); byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(clientKey, "clientKey");
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64"); byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
String preimageStr = "AUTH_CREATE_SESSION:" String preimageStr = "AUTH_CREATE_SESSION:"

View File

@ -74,7 +74,8 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
EspPairingRequestsDAO.getInstance().expirePending(now); EspPairingRequestsDAO.getInstance().expirePending(now);
if (settings.getBlockedUntilMs() > now) { long blockedUntilMs = settings == null ? 0L : settings.getBlockedUntilMs();
if (blockedUntilMs > now) {
return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Временная блокировка pairing по числу неудачных попыток"); return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Временная блокировка pairing по числу неудачных попыток");
} }
int recentAttempts = EspPairingRequestsDAO.getInstance().countRecentByLoginAndStatuses( int recentAttempts = EspPairingRequestsDAO.getInstance().countRecentByLoginAndStatuses(

View File

@ -48,9 +48,9 @@ public final class SolanaUserPdaImportService {
boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain( boolean inserted = UserCreateDAO.getInstance().insertUserWithBlockchain(
parsed.login, parsed.login,
parsed.blockchainName, parsed.blockchainName,
parsed.deviceKeyB64, // в текущей модели solanaKey = deviceKey parsed.clientKeyB64, // в текущей модели solanaKey = clientKey
parsed.blockchainKeyB64, parsed.blockchainKeyB64,
parsed.deviceKeyB64, parsed.clientKeyB64,
sizeLimit, sizeLimit,
now now
); );
@ -158,7 +158,7 @@ public final class SolanaUserPdaImportService {
int blocksCount = u8(raw, c++); int blocksCount = u8(raw, c++);
String blockchainName = null; String blockchainName = null;
byte[] blockchainKey32 = null; byte[] blockchainKey32 = null;
byte[] deviceKey32 = null; byte[] clientKey32 = null;
long paidLimitBytes = 0L; long paidLimitBytes = 0L;
List<ParsedSessionRecord> sessions = new ArrayList<>(); List<ParsedSessionRecord> sessions = new ArrayList<>();
@ -167,10 +167,12 @@ public final class SolanaUserPdaImportService {
int blockVer = u8(raw, c++); int blockVer = u8(raw, c++);
if (blockVer != 0) return null; if (blockVer != 0) return null;
if (blockType == 1) { if (blockType == 0) {
c += 32; // recovery_key
} else if (blockType == 1) {
c += 32; c += 32;
} else if (blockType == 2) { } else if (blockType == 2) {
deviceKey32 = slice(raw, c, 32); clientKey32 = slice(raw, c, 32);
c += 32; c += 32;
} else if (blockType == 3) { } else if (blockType == 3) {
int count = u8(raw, c++); int count = u8(raw, c++);
@ -245,12 +247,12 @@ public final class SolanaUserPdaImportService {
if (c > recordLen) return null; 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( return new ParsedSolanaUser(
login, login,
blockchainName, blockchainName,
Base64.getEncoder().encodeToString(blockchainKey32), Base64.getEncoder().encodeToString(blockchainKey32),
Base64.getEncoder().encodeToString(deviceKey32), Base64.getEncoder().encodeToString(clientKey32),
paidLimitBytes, paidLimitBytes,
sessions sessions
); );
@ -318,7 +320,7 @@ public final class SolanaUserPdaImportService {
String login, String login,
String blockchainName, String blockchainName,
String blockchainKeyB64, String blockchainKeyB64,
String deviceKeyB64, String clientKeyB64,
long paidLimitBytes, long paidLimitBytes,
List<ParsedSessionRecord> sessions 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) 1. Добавление пользователя (AddUser)
Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и deviceKey. Назначение: создать локальную запись пользователя с двумя ключами — solanaKey и clientKey.
📤 Запрос клиента 📤 Запрос клиента
{ {
@ -46,7 +46,7 @@
"loginId": 100212, "loginId": 100212,
"bchId": 4222, "bchId": 4222,
"solanaKey": "BASE64_LOGIN_KEY", "solanaKey": "BASE64_LOGIN_KEY",
"deviceKey": "BASE64_DEVICE_KEY", "clientKey": "BASE64_DEVICE_KEY",
"bchLimit": 1000000 "bchLimit": 1000000
} }
} }
@ -62,7 +62,7 @@ login TEXT NOT NULL,
loginId INTEGER PRIMARY KEY, loginId INTEGER PRIMARY KEY,
bchId INTEGER NOT NULL, bchId INTEGER NOT NULL,
solanaKey TEXT, solanaKey TEXT,
deviceKey TEXT, clientKey TEXT,
bchLimit INTEGER bchLimit INTEGER
); );
@ -118,7 +118,7 @@ timeMs — timestamp клиента (UTC).
sessionPwd — строка с шага 1. sessionPwd — строка с шага 1.
signatureB64 — Ed25519подпись preimage приватным ключом deviceKey. signatureB64 — Ed25519подпись preimage приватным ключом clientKey.
📤 Запрос клиента 📤 Запрос клиента
{ {
@ -141,7 +141,7 @@ signatureB64 — Ed25519подпись preimage приватным ключо
Восстанавливает preimage. Восстанавливает preimage.
Находит deviceKey пользователя. Находит clientKey пользователя.
Проверяет Ed25519-подпись. Проверяет 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; import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** /**
* Шаг 2 (v2): создание новой сессии ТОЛЬКО через deviceKey. * Шаг 2 (v2): создание новой сессии ТОЛЬКО через clientKey.
* *
* Шаги: * Шаги:
* 1) AuthChallenge(login) -> authNonce * 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} * 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 authNonce;
/** Публичный ключ устройства пользователя. */ /** Публичный ключ устройства пользователя. */
private String deviceKey; private String clientKey;
/** Подпись Ed25519(deviceKey) над строкой AUTH_CREATE_SESSION:... (base64). */ /** Подпись Ed25519(clientKey) над строкой AUTH_CREATE_SESSION:... (base64). */
private String signatureB64; private String signatureB64;
/** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */ /** Краткая строка от клиента (до 50 символов) с описанием устройства/клиента. */
@ -87,12 +87,12 @@ public class Net_CreateAuthSession_Request extends Net_Request {
this.authNonce = authNonce; this.authNonce = authNonce;
} }
public String getDeviceKey() { public String getClientKey() {
return deviceKey; return clientKey;
} }
public void setDeviceKey(String deviceKey) { public void setClientKey(String clientKey) {
this.deviceKey = deviceKey; this.clientKey = clientKey;
} }
public String getSignatureB64() { 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.setBlockchainName(u.getBlockchainName());
resp.setSolanaKey(u.getSolanaKey()); resp.setSolanaKey(u.getSolanaKey());
resp.setBlockchainKey(u.getBlockchainKey()); 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", * "blockchainName": "anya-001",
* "solanaKey": "base64-ed25519-public-key-login", * "solanaKey": "base64-ed25519-public-key-login",
* "blockchainKey": "base64-ed25519-public-key-blockchain", * "blockchainKey": "base64-ed25519-public-key-blockchain",
* "deviceKey": "base64-ed25519-public-key-device", * "clientKey": "base64-ed25519-public-key-device",
* "bchLimit": 1000000 * "bchLimit": 1000000
* } * }
* } * }
@ -34,7 +34,7 @@ public class Net_AddUser_Request extends Net_Request {
private String blockchainKey; private String blockchainKey;
/** Ключ устройства (публичный ключ устройства) */ /** Ключ устройства (публичный ключ устройства) */
private String deviceKey; private String clientKey;
private Integer bchLimit; private Integer bchLimit;
@ -50,8 +50,8 @@ public class Net_AddUser_Request extends Net_Request {
public String getBlockchainKey() { return blockchainKey; } public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
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 Integer getBchLimit() { return bchLimit; } public Integer getBchLimit() { return bchLimit; }
public void setBchLimit(Integer bchLimit) { this.bchLimit = 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", * "blockchainName": "anya-001",
* "solanaKey": "...", * "solanaKey": "...",
* "blockchainKey": "...", * "blockchainKey": "...",
* "deviceKey": "..." * "clientKey": "..."
* } * }
* } * }
*/ */
@ -38,7 +38,7 @@ public class Net_GetUser_Response extends Net_Response {
private String blockchainName; private String blockchainName;
private String solanaKey; private String solanaKey;
private String blockchainKey; private String blockchainKey;
private String deviceKey; private String clientKey;
private Integer serverLastGlobalNumber; private Integer serverLastGlobalNumber;
private String serverLastGlobalHash; private String serverLastGlobalHash;
private Long serverBlockchainSizeBytes; private Long serverBlockchainSizeBytes;
@ -59,8 +59,8 @@ public class Net_GetUser_Response extends Net_Response {
public String getBlockchainKey() { return blockchainKey; } public String getBlockchainKey() { return blockchainKey; }
public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; } public void setBlockchainKey(String blockchainKey) { this.blockchainKey = blockchainKey; }
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 Integer getServerLastGlobalNumber() { return serverLastGlobalNumber; } public Integer getServerLastGlobalNumber() { return serverLastGlobalNumber; }
public void setServerLastGlobalNumber(Integer serverLastGlobalNumber) { this.serverLastGlobalNumber = 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.setParam(e.getParam());
resp.setTime_ms(e.getTimeMs()); resp.setTime_ms(e.getTimeMs());
resp.setValue(e.getValue()); resp.setValue(e.getValue());
resp.setDevice_key(e.getDeviceKey()); resp.setClient_key(e.getClientKey());
resp.setSignature(e.getSignature()); resp.setSignature(e.getSignature());
return resp; return resp;

View File

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

View File

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

View File

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

View File

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

View File

@ -44,7 +44,7 @@ final class SignedMessagesCore {
if (from == null || to == null) { if (from == null || to == null) {
throw new IllegalArgumentException("USER_NOT_FOUND"); 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)) { if (!Ed25519Util.verify(block.signedBody, block.signature64, pubKey32)) {
throw new IllegalArgumentException("BAD_SIGNATURE"); throw new IllegalArgumentException("BAD_SIGNATURE");
} }

View File

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

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