Compare commits
26 Commits
475db28095
...
127c561a41
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
127c561a41 | ||
|
|
f9a15ab192 | ||
|
|
77f5759d60 | ||
|
|
684f3237cf | ||
|
|
23e61cc182 | ||
|
|
d2f45ff67a | ||
|
|
06e12e9103 | ||
|
|
29dddeff4f | ||
|
|
017d568aea | ||
|
|
c91b52cfd2 | ||
|
|
2bd38d8d78 | ||
|
|
7d9db68d80 | ||
|
|
4b94303d67 | ||
|
|
08628704c7 | ||
|
|
f1c1132690 | ||
|
|
d2426c473c | ||
|
|
66986b804c | ||
|
|
95daa230bb | ||
|
|
365b22d778 | ||
|
|
cf2b54464e | ||
|
|
4e60c1274a | ||
|
|
2f65e63fbe | ||
|
|
b461431197 | ||
|
|
5c92b6a734 | ||
|
|
ba348dafb3 | ||
|
|
ce2d310e8c |
@ -134,7 +134,7 @@
|
|||||||
|
|
||||||
Что сделать:
|
Что сделать:
|
||||||
|
|
||||||
- продумать и реализовать смену `root key`, `device key`, `blockchain key`;
|
- продумать и реализовать смену `root key`, `client key`, `blockchain key`;
|
||||||
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
|
- описать ограничения, кто и в каком сценарии может менять каждый тип ключа;
|
||||||
- продумать, как не потерять доступ и как обновлять доверие к новым ключам.
|
- продумать, как не потерять доступ и как обновлять доверие к новым ключам.
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
Ограничение:
|
Ограничение:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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` и не становится источником приватных ключей.
|
||||||
|
|||||||
@ -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-модели ответа/запроса и должны передаваться именно в таком виде.
|
||||||
|
|||||||
@ -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 (произвольные именованные ключи сверх базовых трёх)
|
||||||
|
|||||||
@ -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 (~147–160).
|
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160).
|
||||||
|
|
||||||
### 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) —
|
||||||
|
|||||||
@ -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` и восстановления доступа после потери устройства;
|
||||||
- какие типы серверных и аппаратных сессий нужны в первой реализации.
|
- какие типы серверных и аппаратных сессий нужны в первой реализации.
|
||||||
|
|||||||
@ -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` отклоняет заявку и она исчезает из списка активных.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -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`
|
||||||
@ -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`
|
||||||
@ -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`
|
||||||
@ -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`
|
||||||
@ -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`
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Общий список каналов без stories
|
||||||
|
|
||||||
|
- Краткое описание:
|
||||||
|
вкладка `Каналы` переведена на единый список без разделения на "мои" и "подписки".
|
||||||
|
Название канала в списке теперь показывается как `login_владельца/название_канала`.
|
||||||
|
Служебный канал `stories` скрыт из списка каналов, поиска, подписки и связанных UI-сценариев.
|
||||||
|
|
||||||
|
- Что проверять:
|
||||||
|
1. Открыть вкладку `Каналы`.
|
||||||
|
2. Убедиться, что сразу показывается один общий список.
|
||||||
|
3. Проверить, что свои и чужие каналы отображаются вместе.
|
||||||
|
4. Проверить формат названий: `ownerLogin/channelName`.
|
||||||
|
5. Открыть свой канал и убедиться, что внутри сохраняется UI владельца.
|
||||||
|
6. Открыть чужой канал и убедиться, что внутри сохраняется UI подписчика.
|
||||||
|
7. Проверить, что `stories` не отображается:
|
||||||
|
- в общем списке;
|
||||||
|
- в поиске каналов;
|
||||||
|
- в подписке на канал;
|
||||||
|
- в списках выбора канала для репоста.
|
||||||
|
|
||||||
|
- Ожидаемый результат:
|
||||||
|
- вкладка `Каналы` больше не делится на два режима;
|
||||||
|
- все видимые каналы идут единым списком;
|
||||||
|
- `stories` нигде не виден и не предлагается пользователю;
|
||||||
|
- переход в канал сохраняет корректный UI в зависимости от владельца.
|
||||||
|
|
||||||
|
- Статус:
|
||||||
|
`pending`
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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` |
|
||||||
|
|
||||||
Если эти адреса меняются, нужно синхронно обновить:
|
Если эти адреса меняются, нужно синхронно обновить:
|
||||||
|
|||||||
@ -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` |
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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 |
@ -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 по хэшу — указать на чужую инструкцию нельзя.
|
||||||
|
|||||||
@ -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-классификации логина.
|
||||||
Несовпадение адреса приведёт к ошибке регистрации.
|
Несовпадение адреса приведёт к ошибке регистрации.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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
28
ESP32/AGENTS.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# AGENTS for ESP32
|
||||||
|
|
||||||
|
## Язык UI
|
||||||
|
|
||||||
|
- Для ESP32-скетчей и экранного UI использовать английский язык.
|
||||||
|
- Русский текст на экране ESP32 пока не поддерживается корректно: шрифтовой путь для кириллицы не считается рабочим.
|
||||||
|
- Если меняется UI-скетч, все пользовательские строки на экране должны оставаться английскими, пока ограничение не снято отдельной задачей.
|
||||||
|
|
||||||
|
## Синхронизация со спецификацией
|
||||||
|
|
||||||
|
- При изменении экранов, кнопок, переходов, статусов или текстов обязательно обновлять соответствующую спецификацию в `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/`.
|
||||||
|
|
||||||
|
## Сборка ESP32
|
||||||
|
|
||||||
|
- Основной способ проверки и прошивки скетчей для `ESP32-S3-Touch-AMOLED-2.16` - `main-device/burn.sh`.
|
||||||
|
- Не собирать эти скетчи напрямую через `arduino-cli compile` без `burn.sh`, потому что скрипт добавляет нужные локальные библиотеки и конфиги из `official-demo/examples/Arduino-v3.3.5/libraries`.
|
||||||
|
- Если сборка падает по `lv_conf.h` или `TouchDrvCSTXXX.hpp`, сначала проверять именно `burn.sh` и его `--library` пути, а не считать, что файл пропал из репозитория.
|
||||||
|
|
||||||
|
## Диагностика ESP32
|
||||||
|
|
||||||
|
- Последнюю сохранённую ошибку или диагностическую запись читать с устройства через USB serial monitor на `115200`.
|
||||||
|
- Базовая команда:
|
||||||
|
`arduino-cli monitor -p /dev/ttyACM0 --config baudrate=115200`
|
||||||
|
- После подключения отправлять одну из команд:
|
||||||
|
`last_error`, `last_diag` или `reg_diag`
|
||||||
|
- Для очистки сохранённой диагностики использовать:
|
||||||
|
`clear_error` или `clear_diag`
|
||||||
|
- При падениях в ветках регистрации и обновления PDA сначала читать именно `last_error`: запись хранится в NVS и может пережить перезагрузку устройства.
|
||||||
@ -11,7 +11,7 @@
|
|||||||
* legacy(empty password):
|
* 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)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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+");
|
||||||
|
|||||||
@ -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 = "Удалить секрет, кошелёк и статус регистрации?";
|
||||||
|
|||||||
@ -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`
|
||||||
|
|
||||||
## Хранение аккаунта
|
## Хранение аккаунта
|
||||||
|
|
||||||
|
|||||||
@ -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. секрет и адрес кошелька сохраняются на устройстве;
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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,8 +372,17 @@ async function attachApprovedSession(payload) {
|
|||||||
throw new Error('Получен неполный session-only payload');
|
throw new Error('Получен неполный session-only payload');
|
||||||
}
|
}
|
||||||
|
|
||||||
await clearSessionMaterial();
|
state.sessionAttachInProgress = true;
|
||||||
|
try {
|
||||||
state.activeSession = sessionRecord;
|
state.activeSession = sessionRecord;
|
||||||
|
state.walletProfile = null;
|
||||||
|
state.currentWallet = null;
|
||||||
|
state.signing = {
|
||||||
|
...state.signing,
|
||||||
|
selectedDeviceName: '',
|
||||||
|
devicesResolvedAtMs: 0,
|
||||||
|
};
|
||||||
|
await saveActiveSessionRecord();
|
||||||
await hydrateWalletProfile(login);
|
await hydrateWalletProfile(login);
|
||||||
await saveActiveSessionRecord();
|
await saveActiveSessionRecord();
|
||||||
await persistSettings({
|
await persistSettings({
|
||||||
@ -293,6 +392,10 @@ async function attachApprovedSession(payload) {
|
|||||||
serverUrl: sessionRecord.serverUrl,
|
serverUrl: sessionRecord.serverUrl,
|
||||||
});
|
});
|
||||||
state.connectionOnline = false;
|
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(() => {});
|
||||||
|
|||||||
93
SHiNE-browser-plugin-wallet/content-script.js
Normal file
93
SHiNE-browser-plugin-wallet/content-script.js
Normal 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();
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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:
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
import { PublicKey } from '@solana/web3.js';
|
import { PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js';
|
||||||
|
|
||||||
export { PublicKey };
|
export { PublicKey, Transaction, VersionedTransaction };
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export class WsJsonClient {
|
|||||||
this.ws = null;
|
this.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);
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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,25 +33,64 @@
|
|||||||
<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>
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
433
SHiNE-browser-plugin-wallet/provider-bridge.js
Normal file
433
SHiNE-browser-plugin-wallet/provider-bridge.js
Normal 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'));
|
||||||
|
}
|
||||||
@ -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
@ -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)"
|
|
||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
@ -140,7 +140,7 @@ public final class DatabaseInitializer {
|
|||||||
// 1. solana_users
|
// 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)
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
@ -10,7 +10,7 @@ import shine.db.entities.ActiveSessionEntry;
|
|||||||
*
|
*
|
||||||
* Важно (v2):
|
* Важно (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;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
package server.logic.ws_protocol.JSON.entyties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Базовый класс для всех событий (event).
|
|
||||||
* Общие поля: op и payload.
|
|
||||||
*.
|
|
||||||
* Формат JSON (event):
|
|
||||||
* {
|
|
||||||
* "op": "...",
|
|
||||||
* "payload": { ... }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public abstract class Net_Event {
|
|
||||||
|
|
||||||
/** Имя операции / события (op). */
|
|
||||||
private String op;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Произвольные данные.
|
|
||||||
* В JSON это поле "payload".
|
|
||||||
*/
|
|
||||||
private Object payload;
|
|
||||||
|
|
||||||
// --- getters / setters ---
|
|
||||||
|
|
||||||
public String getOp() {
|
|
||||||
return op;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setOp(String op) {
|
|
||||||
this.op = op;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Object getPayload() {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setPayload(Object payload) {
|
|
||||||
this.payload = payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
package server.logic.ws_protocol.JSON.entyties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ответ с ошибкой (любой отказ).
|
|
||||||
*.
|
|
||||||
* В payload будет:
|
|
||||||
* {
|
|
||||||
* "code": "...",
|
|
||||||
* "message": "..."
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public class Net_Exception_Response extends Net_Response {
|
|
||||||
|
|
||||||
private String code;
|
|
||||||
private String message;
|
|
||||||
|
|
||||||
public String getCode() {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCode(String code) {
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getMessage() {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setMessage(String message) {
|
|
||||||
this.message = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
package server.logic.ws_protocol.JSON.entyties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Базовый класс для всех запросов (client → server).
|
|
||||||
*.
|
|
||||||
* Наследуется от NetEvent и добавляет requestId.
|
|
||||||
*.
|
|
||||||
* Формат JSON (request):
|
|
||||||
* {
|
|
||||||
* "op": "...",
|
|
||||||
* "requestId": "...",
|
|
||||||
* "payload": { ... }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public abstract class Net_Request extends Net_Event {
|
|
||||||
|
|
||||||
/** Идентификатор запроса, чтобы связать запрос и ответ. */
|
|
||||||
private String requestId;
|
|
||||||
|
|
||||||
// --- getters / setters ---
|
|
||||||
|
|
||||||
public String getRequestId() {
|
|
||||||
return requestId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRequestId(String requestId) {
|
|
||||||
this.requestId = requestId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
package server.logic.ws_protocol.JSON.entyties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Базовый класс для всех ответов (server → client).
|
|
||||||
*.
|
|
||||||
* Наследуется от NetRequest и добавляет status.
|
|
||||||
*.
|
|
||||||
* Формат JSON (response):
|
|
||||||
* {
|
|
||||||
* "op": "...",
|
|
||||||
* "requestId": "...",
|
|
||||||
* "status": 200,
|
|
||||||
* "payload": { ... } // и для успеха, и для ошибки
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public abstract class Net_Response extends Net_Request {
|
|
||||||
|
|
||||||
/** Статус результата (200 — успех, любое другое значение — ошибка). */
|
|
||||||
private int status;
|
|
||||||
|
|
||||||
// --- getters / setters ---
|
|
||||||
|
|
||||||
public int getStatus() {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStatus(int status) {
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isOk() {
|
|
||||||
return status == 200;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||||
|
|||||||
@ -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:"
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
@ -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-подпись.
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUTFILE="all_files.txt"
|
|
||||||
|
|
||||||
# очищаем или создаём файл
|
|
||||||
: > "$OUTFILE"
|
|
||||||
|
|
||||||
# собрать только *.java файлы и вывести их содержимое в файл
|
|
||||||
find . -type f -name "*.java" | sort | while read -r f; do
|
|
||||||
cat "$f" >> "$OUTFILE"
|
|
||||||
echo >> "$OUTFILE" # пустая строка-разделитель
|
|
||||||
done
|
|
||||||
|
|
||||||
# скопировать весь файл в буфер обмена (Wayland)
|
|
||||||
wl-copy < "$OUTFILE"
|
|
||||||
|
|
||||||
echo "Готово!"
|
|
||||||
echo "Все .java файлы собраны в $OUTFILE"
|
|
||||||
echo "Содержимое скопировано в буфер обмена (Wayland)"
|
|
||||||
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)"
|
|
||||||
@ -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());
|
||||||
|
|
||||||
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
|
// Возвращаем актуальный курсор блокчейна и, если запись состояния потеряна,
|
||||||
// автоматически восстанавливаем её для существующего пользователя.
|
// автоматически восстанавливаем её для существующего пользователя.
|
||||||
|
|||||||
@ -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",
|
|
||||||
"Внутренняя ошибка сервера"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)"
|
|
||||||
@ -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; }
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
|
||||||
"Внутренняя ошибка сервера"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)"
|
|
||||||
@ -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; }
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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", "Подпись не прошла проверку");
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user