Compare commits
5 Commits
c397c28acb
...
ed83b1f906
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
ed83b1f906 | ||
|
|
3068c3e2b8 | ||
|
|
93c6f247f7 | ||
|
|
05a9441493 | ||
|
|
aa02e92e4d |
@ -2,12 +2,13 @@
|
||||
|
||||
Этот файл описывает технические WebSocket-запросы, которые нужны для служебной работы клиента с сервером. Часть операций доступна без авторизации, часть требует успешной авторизованной сессии.
|
||||
|
||||
Сейчас здесь восемь методов:
|
||||
Сейчас здесь девять методов:
|
||||
|
||||
- `Ping` — keep-alive запрос для поддержания живого WebSocket-соединения;
|
||||
- `GetServerInfo` — запрос базовой публичной информации о сервере для выбора узла в децентрализованной сети;
|
||||
- `ListBlockchainHeads` — краткая сводка по всем локальным блокчейнам сервера для межсерверной синхронизации;
|
||||
- `GetSyncUserProfile` — межсерверный профиль пользователя для создания локальной цепочки без Solana RPC;
|
||||
- `SendSignal` — общий межсессионный технический сигнал в одну конкретную сессию или сразу во все активные сессии пользователя;
|
||||
- `GetCallIceConfig` — выдача STUN/TURN конфигурации для звонков;
|
||||
- `ClientErrorLog` — отправка клиентской ошибки в серверный лог;
|
||||
- `ClientDebugLog` — отправка клиентского debug-события в серверный буфер;
|
||||
@ -19,6 +20,7 @@
|
||||
- `GetServerInfo` нужен до авторизации и до работы с данными, чтобы клиент понял, что сервер доступен, и показал пользователю краткую карточку этого узла.
|
||||
- `ListBlockchainHeads` нужен для сервер-сервер сверки: партнёр получает список heads по всем цепочкам, сравнивает его со своим состоянием и затем добирает недостающие блоки по диапазону.
|
||||
- `GetSyncUserProfile` нужен для server-to-server режима, когда принимающий сервер хочет создать у себя локальные `solana_users + blockchain_state` без прямого обращения в Solana. Это используется как временный обход ограничений внешнего Solana RPC.
|
||||
- `SendSignal` нужен для доверенных межсессионных команд одного пользователя. Первое практическое применение — `remote AddBlock via homeserver session`, но формат задуман как общий transport на вырост.
|
||||
|
||||
Ниже сначала описаны назначение методов, затем точные форматы запросов и ответов.
|
||||
|
||||
@ -282,7 +284,158 @@
|
||||
|
||||
---
|
||||
|
||||
## 5. `GetCallIceConfig`
|
||||
## 5. `SendSignal`
|
||||
|
||||
Доступно только после успешной авторизации.
|
||||
|
||||
### Назначение
|
||||
|
||||
Общий межсессионный технический сигнал.
|
||||
|
||||
Этот метод нужен для случаев, когда одна активная сессия пользователя должна быстро передать служебную команду другой сессии того же пользователя или сразу всем его активным сессиям.
|
||||
|
||||
Первый целевой сценарий:
|
||||
|
||||
- `remote AddBlock via homeserver session`
|
||||
|
||||
То есть телефон без локального `blockchain.key` может:
|
||||
|
||||
- подготовить только сырой payload операции без текущей вершины цепочки;
|
||||
- подписать сам `SendSignal` своим `session key`;
|
||||
- дополнительно подписать его `client key`, чтобы homeserver/ESP32 точно видел, что запрос пришёл от доверенного клиента этого же логина;
|
||||
- отправить запрос в выбранную `homeserver`-сессию;
|
||||
- получить от неё ответ после настоящего `AddBlock`, который homeserver соберёт и подпишет уже сама.
|
||||
|
||||
### Режимы доставки
|
||||
|
||||
- `targetMode = "single_session"` — доставка в одну конкретную `targetSessionId`.
|
||||
- `targetMode = "all_sessions"` — доставка во все активные сессии указанного логина.
|
||||
|
||||
### Важное правило подписи
|
||||
|
||||
Сам `SendSignal` не подписывает поле `data` отдельной вложенной подписью. Вместо этого сервер проверяет подписи по общему preimage сигнала, в который входит:
|
||||
|
||||
- `fromLogin`
|
||||
- `fromSessionId`
|
||||
- `toLogin`
|
||||
- `targetMode`
|
||||
- `targetSessionId`
|
||||
- `signalType`
|
||||
- `signalRequestId`
|
||||
- `timeMs`
|
||||
- `sha256(data)`
|
||||
|
||||
Поддерживаются две подписи:
|
||||
|
||||
- `sessionSignatureB64` — обязательная подпись текущей авторизованной `session key`;
|
||||
- `clientSignatureB64` — необязательная подпись `client key`.
|
||||
|
||||
Для сценария `remote AddBlock via homeserver` текущая договорённость такая:
|
||||
|
||||
- запрос должен идти только своему же логину;
|
||||
- запрос должен быть подписан и `session key`, и `client key`;
|
||||
- в будущем для отдельных wallet-сценариев `clientSignatureB64` может быть пустой.
|
||||
|
||||
### Запрос в одну сессию
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SendSignal",
|
||||
"requestId": "ws-req-001",
|
||||
"payload": {
|
||||
"toLogin": "alice",
|
||||
"targetMode": "single_session",
|
||||
"targetSessionId": "sess-hs-001",
|
||||
"signalType": "remote_addblock_request",
|
||||
"signalRequestId": "remote-addblock-001",
|
||||
"data": "{\"operation\":\"remote_addblock_request\",\"signalRequestId\":\"remote-addblock-001\",\"blockchainName\":\"alice_main\",\"blockBodyB64\":\"...\"}",
|
||||
"timeMs": 1774700000123,
|
||||
"sessionSignatureB64": "BASE64_64",
|
||||
"clientSignatureB64": "BASE64_64"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Успешный ответ
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "SendSignal",
|
||||
"requestId": "ws-req-001",
|
||||
"status": 200,
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"deliveredCount": 1,
|
||||
"deliveredSessionIds": ["sess-hs-001"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Событие на принимающей стороне
|
||||
|
||||
```json
|
||||
{
|
||||
"op": "IncomingSignal",
|
||||
"eventId": "evt-001",
|
||||
"payload": {
|
||||
"fromLogin": "alice",
|
||||
"fromSessionId": "sess-phone-001",
|
||||
"toLogin": "alice",
|
||||
"targetMode": "single_session",
|
||||
"targetSessionId": "sess-hs-001",
|
||||
"signalType": "remote_addblock_request",
|
||||
"signalRequestId": "remote-addblock-001",
|
||||
"data": "{\"operation\":\"remote_addblock_request\",\"signalRequestId\":\"remote-addblock-001\",\"blockchainName\":\"alice_main\",\"blockBodyB64\":\"...\"}",
|
||||
"timeMs": 1774700000123,
|
||||
"sessionSignatureB64": "BASE64_64",
|
||||
"clientSignatureB64": "BASE64_64",
|
||||
"dataSha256B64": "BASE64_32"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Специфика `remote AddBlock`
|
||||
|
||||
Для `remote_addblock_request` поле `data` теперь содержит:
|
||||
|
||||
- `blockchainName`
|
||||
- `blockBodyB64`
|
||||
|
||||
Где `blockBodyB64` — это не финальный блок и не почти готовый preimage, а компактный бинарный контейнер:
|
||||
|
||||
- `msgType` (`u16`)
|
||||
- `msgSubType` (`u16`)
|
||||
- `msgVersion` (`u16`)
|
||||
- `bodyBytes`
|
||||
|
||||
После этого homeserver сама:
|
||||
|
||||
- вызывает `GetUser(login)` и получает `serverLastGlobalNumber/serverLastGlobalHash`;
|
||||
- вычисляет новый `blockNumber = last + 1`;
|
||||
- подставляет актуальный `prevBlockHash`;
|
||||
- ставит текущее время;
|
||||
- досчитывает полный preimage;
|
||||
- подписывает его своим `blockchain key`;
|
||||
- и только потом делает настоящий `AddBlock`.
|
||||
|
||||
### Специфические коды ошибок `SendSignal`
|
||||
|
||||
- `422 / NOT_AUTHENTICATED` — требуется авторизация.
|
||||
- `400 / BAD_FIELDS` — не хватает обязательных полей или нарушено правило `single_session/all_sessions`.
|
||||
- `400 / BAD_TARGET_MODE` — передан неизвестный `targetMode`.
|
||||
- `400 / TIME_SKEW` — `timeMs` отличается от серверного более чем на 30 секунд.
|
||||
- `500 / NO_CLIENT_KEY` — для текущего пользователя не найден `client key`.
|
||||
- `404 / USER_NOT_FOUND` — логин адресата не найден.
|
||||
- `400 / BAD_DATA` — сервер не смог обработать `data`.
|
||||
- `400 / BAD_SESSION_SIGNATURE` — некорректная подпись `session key`.
|
||||
- `400 / BAD_CLIENT_SIGNATURE` — некорректная подпись `client key`.
|
||||
- `404 / SESSION_NOT_FOUND` — при `single_session` целевая сессия не найдена или не онлайн.
|
||||
- `404 / NO_TARGET_SESSIONS` — при `all_sessions` у пользователя сейчас нет активных онлайн-сессий.
|
||||
- `404 / DELIVERY_FAILED` — сервер не смог отправить событие ни в одну из целевых сессий.
|
||||
|
||||
---
|
||||
|
||||
## 6. `GetCallIceConfig`
|
||||
|
||||
Доступно только после успешной авторизации.
|
||||
|
||||
@ -332,7 +485,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 6. `ClientErrorLog`
|
||||
## 7. `ClientErrorLog`
|
||||
|
||||
### Запрос
|
||||
|
||||
@ -379,7 +532,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. `ClientDebugLog`
|
||||
## 8. `ClientDebugLog`
|
||||
|
||||
### Запрос
|
||||
|
||||
@ -417,7 +570,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 8. `CallDeliveryReport`
|
||||
## 9. `CallDeliveryReport`
|
||||
|
||||
### Запрос
|
||||
|
||||
@ -453,29 +606,10 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. Короткое резюме
|
||||
## 10. Короткое резюме
|
||||
|
||||
- `Ping` нужен для keep-alive и проверки, что WebSocket-соединение живо.
|
||||
- `GetServerInfo` нужен для выбора сервера в сети и показа публичной информации об узле.
|
||||
- `SendSignal` нужен для доверенных межсессионных сигналов одного пользователя, включая `remote AddBlock via homeserver session`.
|
||||
- `GetCallIceConfig` нужен для WebRTC-звонков и требует авторизации.
|
||||
- `ClientErrorLog`, `ClientDebugLog`, `CallDeliveryReport` используются для диагностики клиента и звонков.
|
||||
|
||||
|
||||
## 8. Прямое техническое сообщение в конкретную сессию
|
||||
|
||||
На текущий момент в публичном JSON API этого документа **нет отдельного RPC** для отправки произвольного технического сообщения в конкретную сессию пользователя (по `sessionId`).
|
||||
|
||||
Что уже есть в системе:
|
||||
|
||||
- сервер хранит `sessionId` активной сессии;
|
||||
- есть `ListSessions`, чтобы клиент получил список sessionId своего пользователя;
|
||||
- у сервера есть внутренний реестр активных WS-подключений по `sessionId`.
|
||||
|
||||
Чего не хватает для полноценной фичи «direct tech message by sessionId»:
|
||||
|
||||
1. отдельная API-операция (например, `SendSessionTechMessage`);
|
||||
2. правило авторизации (кто имеет право писать в чужую/свою сессию);
|
||||
3. унифицированный формат payload и события доставки;
|
||||
4. коды ошибок (`SESSION_OFFLINE`, `SESSION_NOT_FOUND`, `FORBIDDEN` и т.п.).
|
||||
|
||||
Итог: как инфраструктурная база это почти готово, но нужен отдельный RPC-слой и политика доступа.
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
||||
| `ListBlockchainHeads` | `05_Technical_Requests_API.md` | список heads всех локальных блокчейнов |
|
||||
| `GetSyncUserProfile` | `05_Technical_Requests_API.md` | межсерверный профиль пользователя для синхронизации |
|
||||
| `SendSignal` | `05_Technical_Requests_API.md` | общий межсессионный технический сигнал в одну или все сессии пользователя |
|
||||
| `GetCallIceConfig` | `05_Technical_Requests_API.md` | STUN/TURN конфигурация звонков |
|
||||
| `ClientErrorLog` | `05_Technical_Requests_API.md` | логирование клиентской ошибки |
|
||||
| `ClientDebugLog` | `05_Technical_Requests_API.md` | клиентский debug-лог |
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
# Remote AddBlock через homeserver
|
||||
|
||||
- Статус: `pending`
|
||||
- Дата: `2026-06-28 13:30`
|
||||
|
||||
## Что сделано
|
||||
|
||||
Добавлен общий серверный API `SendSignal` и первый сценарий его использования:
|
||||
|
||||
- клиент без локального `blockchain.key` выбирает `homeserver`-сессию (`sessionType = 100`);
|
||||
- клиент отправляет в неё `remote_addblock_request` через `SendSignal`;
|
||||
- запрос подписывается `session key` и `client key`;
|
||||
- UI больше не передаёт `blockNumber` и `prevBlockHash`;
|
||||
- UI передаёт только `blockchainName + blockBodyB64`;
|
||||
- ESP32/homeserver сама делает `GetUser(login)`, получает актуальную вершину цепочки, собирает финальный блок, подписывает настоящий `AddBlock` своим `blockchain key` и сам отправляет его на сервер;
|
||||
- результат возвращается назад сигналом `remote_addblock_result`.
|
||||
|
||||
## Что проверить вручную
|
||||
|
||||
1. На клиенте без локального `blockchain.key` открыть настройки и выбрать активную `homeserver`-сессию для remote AddBlock.
|
||||
2. Выполнить любое действие UI, которое приводит к `AddBlock`.
|
||||
3. Убедиться, что клиент не падает в ошибку отсутствия `blockchain.key`, а отправляет `SendSignal`.
|
||||
4. Убедиться, что ESP32/homeserver получает `IncomingSignal` с `remote_addblock_request`.
|
||||
5. Убедиться, что homeserver отправляет обычный `AddBlock` на сервер.
|
||||
6. Убедиться, что клиент получает `remote_addblock_result` и завершает исходную операцию как успешную.
|
||||
7. Проверить негативный сценарий:
|
||||
- homeserver-сессия не выбрана;
|
||||
- homeserver офлайн;
|
||||
- homeserver возвращает ошибку `AddBlock`.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- При наличии локального `blockchain.key` клиент продолжает работать по старому локальному пути.
|
||||
- При отсутствии локального `blockchain.key` и выбранной `homeserver`-сессии `AddBlock` проходит через удалённую подпись и удалённую отправку.
|
||||
- В ошибочных сценариях пользователь получает понятную ошибку без скрытого fallback.
|
||||
@ -0,0 +1,38 @@
|
||||
# Поддержать проект Сияние
|
||||
|
||||
Статус: `pending`
|
||||
|
||||
## Кратко
|
||||
|
||||
В `shine-UI/js/pages/wallet-view.js` добавлен новый раздел `Поддержать проект Сияние` с тремя входами:
|
||||
|
||||
1. купить билет;
|
||||
2. посмотреть билет по номеру;
|
||||
3. сгенерировать новую пару ключей.
|
||||
|
||||
## Что проверить
|
||||
|
||||
1. Открыть `Кошелёк`.
|
||||
2. Перейти в `Поддержать проект Сияние`.
|
||||
3. Проверить экран покупки:
|
||||
- виден коэффициент;
|
||||
- виден остаток лимита очереди 1;
|
||||
- виден расчет в SOL;
|
||||
- кнопка `Справка` открывает отдельный экран;
|
||||
- покупка блокируется, если сумма больше остатка лимита.
|
||||
4. Проверить экран просмотра:
|
||||
- `12` ищется как билет очереди 1;
|
||||
- `2-5` и `3 8` ищутся как билеты очередей 2 и 3;
|
||||
- показываются статус, количество билетов до него и уже выплаченные значения.
|
||||
5. Проверить генератор ключей:
|
||||
- генерируется новая пара ключей;
|
||||
- публичный и секретный ключи показываются;
|
||||
- можно скопировать и скачать результат;
|
||||
- дополнительный текст в поле необязателен.
|
||||
|
||||
## Ожидаемый результат
|
||||
|
||||
- Экран раздела поддержки открывается из `wallet-view`.
|
||||
- Покупка билета выполняется по текущему курсу и с допуском 3%.
|
||||
- По номеру билета показывается понятная сводка по очереди.
|
||||
- Генерация ключей использует безопасный браузерный рандом и не требует сохранения секретного ключа.
|
||||
@ -1,6 +1,6 @@
|
||||
# ESP Pairing и режимы подключения
|
||||
|
||||
Этот документ фиксирует текущие и новые режимы входа/подключения в SHiNE без клиентской UI-реализации. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя.
|
||||
Этот документ фиксирует актуальные режимы входа/подключения в SHiNE. Он нужен как отдельная точка входа по сценариям подключения, чтобы не смешивать обычную авторизацию и серверный pairing через доверенное уже авторизованное устройство пользователя.
|
||||
|
||||
## 1. Текущие режимы
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
- соединение снова входит в существующую сессию;
|
||||
- этот поток тоже остаётся без изменений.
|
||||
|
||||
## 2. Новый режим: добавление сессии через доверенное устройство пользователя
|
||||
## 2. Добавление сессии через доверенное устройство пользователя
|
||||
|
||||
Новый поток не заменяет обычный логин, а живёт рядом с ним.
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
- реальное доверие даёт любая уже онлайн доверенная сессия пользователя;
|
||||
- сервер не выдаёт приватные ключи сам от себя.
|
||||
|
||||
Поток версии `v1`:
|
||||
Текущий поток:
|
||||
|
||||
1. Любая доверенная сессия пользователя создаёт на сервере pairing-настройку:
|
||||
`UpsertEspPairingSettings`
|
||||
@ -65,7 +65,7 @@
|
||||
- уведомляет онлайн доверенные сессии событием `IncomingEspPairingRequest`, если такие сессии подключены;
|
||||
- хранит переданный `encryptedPayload` как непрозрачную строку и не анализирует его содержимое.
|
||||
|
||||
## 4. Чего сервер в этой версии не делает
|
||||
## 4. Чего сервер в этом режиме не делает
|
||||
|
||||
- не передаёт приватный `clientKey`;
|
||||
- не расшифровывает `encryptedPayload`;
|
||||
@ -73,7 +73,7 @@
|
||||
- не делает клиентский UI;
|
||||
- не навязывает конкретную схему `Ed25519 -> X25519` в коде сервера.
|
||||
|
||||
Это намеренно: серверная версия `v1` подготавливает безопасный каркас маршрутизации и состояния, а настоящая E2E-логика упаковки ключей будет жить на клиентах и ESP-устройствах.
|
||||
Это намеренно: сервер остаётся безопасным каркасом маршрутизации и состояния, а E2E-логика упаковки ключей живёт на клиентах и ESP-устройствах.
|
||||
|
||||
## 5. Роли и ограничения
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Формат взаимодействия внешнего кошелька и ESP32
|
||||
|
||||
Этот документ фиксирует первый этап формата взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`.
|
||||
Этот документ фиксирует актуальный формат взаимодействия между внешним браузерным wallet-расширением SHiNE и устройством `ESP32-S3-Touch-AMOLED-2.16`.
|
||||
|
||||
Документ описывает:
|
||||
|
||||
@ -39,13 +39,13 @@ ESP32 возвращает:
|
||||
|
||||
## 2. Транспорт и маршрут
|
||||
|
||||
Первая версия формата использует уже существующую `wallet-session` браузерного расширения.
|
||||
Текущий формат использует уже существующую `wallet-session` браузерного расширения.
|
||||
|
||||
Схема маршрута:
|
||||
|
||||
`browser extension -> SHiNE server -> homeserver session on ESP32 -> SHiNE server -> browser extension`
|
||||
|
||||
В первой версии:
|
||||
В текущем формате:
|
||||
|
||||
- отдельная цифровая подпись payload не добавляется;
|
||||
- отдельное E2E-шифрование для wallet RPC не добавляется;
|
||||
@ -90,7 +90,7 @@ ESP32 возвращает:
|
||||
- `customName`;
|
||||
- `targetSessionName`.
|
||||
|
||||
Они намеренно не входят в первую версию этого запроса.
|
||||
Они намеренно не входят в текущий формат этого запроса.
|
||||
|
||||
## 4. Формат ответа
|
||||
|
||||
@ -121,9 +121,9 @@ ESP32 возвращает:
|
||||
- `wallet.publicKeyBase58` — публичный ключ активного кошелька в `Base58`.
|
||||
- `timeMs` — время формирования ответа на стороне ESP32 в миллисекундах.
|
||||
|
||||
## 6. Ошибки первой версии
|
||||
## 6. Ошибки текущего формата
|
||||
|
||||
Минимальный формат ошибки в первой версии допускается таким:
|
||||
Минимальный формат ошибки допускается таким:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -136,7 +136,7 @@ ESP32 возвращает:
|
||||
}
|
||||
```
|
||||
|
||||
Рекомендуемые коды ошибок первой версии:
|
||||
Рекомендуемые коды ошибок:
|
||||
|
||||
- `wallet_unavailable` — на устройстве нельзя получить текущий кошелёк;
|
||||
- `secret_not_configured` — на устройстве ещё нет корректно сохранённого секрета;
|
||||
@ -158,7 +158,7 @@ ESP32 возвращает:
|
||||
|
||||
- если `wallet.type = client.key`, то `publicKeyBase58` должен совпасть с `clientKey`, прочитанным из PDA;
|
||||
- если `wallet.type = root.key`, то `publicKeyBase58` должен совпасть с `rootKey`, прочитанным из PDA;
|
||||
- если `wallet.type = custom`, такой проверки по PDA в первой версии нет.
|
||||
- если `wallet.type = custom`, такой проверки по PDA пока нет.
|
||||
|
||||
При несовпадении для `client.key` или `root.key` расширение должно показать пользователю предупреждение, что возвращённый ключ не совпал с ожидаемым ключом из PDA.
|
||||
|
||||
@ -308,4 +308,4 @@ ESP32:
|
||||
- выбор типа кошелька делается только на самом ESP32;
|
||||
- отдельная цифровая подпись ответа пока не используется;
|
||||
- отдельное E2E-шифрование wallet RPC пока не используется;
|
||||
- `custom`-кошельки в первой версии не сверяются с PDA.
|
||||
- `custom`-кошельки пока не сверяются с PDA.
|
||||
|
||||
@ -285,6 +285,17 @@ struct ActiveWalletSignRequest {
|
||||
Screen returnScreen = SCREEN_HOME;
|
||||
};
|
||||
|
||||
struct PendingRemoteAddBlockRequest {
|
||||
String fromLogin;
|
||||
String fromSessionId;
|
||||
String signalRequestId;
|
||||
String signalType;
|
||||
String data;
|
||||
String sessionSignatureB64;
|
||||
String clientSignatureB64;
|
||||
uint64_t timeMs = 0;
|
||||
};
|
||||
|
||||
static lv_disp_draw_buf_t gDrawBuf;
|
||||
static lv_color_t *gBuf1 = nullptr;
|
||||
static lv_color_t *gBuf2 = nullptr;
|
||||
@ -415,6 +426,7 @@ static String gCustomWalletName;
|
||||
static String gCustomWalletPubB58;
|
||||
static String gCustomWalletPrivB58;
|
||||
static std::vector<PendingWalletRpcRequest> gPendingWalletRpcRequests;
|
||||
static std::vector<PendingRemoteAddBlockRequest> gPendingRemoteAddBlockRequests;
|
||||
static const int kWalletRpcSignalTypeRequest = 9100;
|
||||
static const int kWalletRpcSignalTypeResponse = 9101;
|
||||
static ActiveWalletSignRequest gActiveWalletSignRequest;
|
||||
@ -477,7 +489,9 @@ static void refreshSelectedWalletBalanceState();
|
||||
static bool loadSelectedWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut);
|
||||
static bool loadBalanceLamportsForAddress(const String &address, uint64_t &lamportsOut, String &messageOut);
|
||||
static bool queueIncomingWalletRpcRequest(const String &frame);
|
||||
static bool queueIncomingRemoteAddBlockRequest(const String &frame);
|
||||
static void processPendingWalletRpcRequests();
|
||||
static void processPendingRemoteAddBlockRequests();
|
||||
static void pumpShineIncomingFrames(uint32_t maxFrames = 3);
|
||||
static String loginDisplayValue();
|
||||
static String homeserverDisplayValue();
|
||||
@ -496,11 +510,13 @@ static void saveShineSessionPrefs();
|
||||
static String normalizeLoginValue(const String &value);
|
||||
static bool base58ToFixed32(const String &value, uint8_t out[32]);
|
||||
static bool base64DecodeStd(const String &value, std::vector<uint8_t> &out);
|
||||
static bool hex64ToBytes(const String &value, uint8_t out[32]);
|
||||
static String bytesToBase64String(const uint8_t *data, size_t len);
|
||||
static String jsonEscape(const String &value);
|
||||
static bool jsonStringField(const String &json, const String &field, String &valueOut);
|
||||
static bool jsonBoolField(const String &json, const String &field, bool &valueOut);
|
||||
static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut);
|
||||
static bool jsonSignedInt64Field(const String &json, const String &field, int64_t &valueOut);
|
||||
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
|
||||
static String formatPairingShortCode(const String &value);
|
||||
static bool pairingMenuVisible();
|
||||
@ -617,6 +633,22 @@ static bool sendWalletRpcResponse(const String &toLogin,
|
||||
const String &targetSessionId,
|
||||
const String &callId,
|
||||
const String &responseData);
|
||||
static bool sendSignalResponse(const String &toLogin,
|
||||
const String &targetSessionId,
|
||||
const String &signalType,
|
||||
const String &signalRequestId,
|
||||
const String &responseData);
|
||||
static bool buildSendSignalSignatures(const String &toLogin,
|
||||
const String &targetMode,
|
||||
const String &targetSessionId,
|
||||
const String &signalType,
|
||||
const String &signalRequestId,
|
||||
const String &data,
|
||||
uint64_t timeMs,
|
||||
bool includeClientSignature,
|
||||
String &sessionSignatureB64Out,
|
||||
String &clientSignatureB64Out,
|
||||
String &errorOut);
|
||||
static void queueWalletSignRequest(const PendingWalletRpcRequest &item,
|
||||
const String &requestId,
|
||||
const String &publicKeyBase58,
|
||||
@ -881,6 +913,14 @@ static String bytesToBase64String(const uint8_t *data, size_t len) {
|
||||
return base64Std(data, len);
|
||||
}
|
||||
|
||||
static String trimBase64Padding(const String &value) {
|
||||
String out = value;
|
||||
while (out.endsWith("=")) {
|
||||
out.remove(out.length() - 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String bytesToHexString(const uint8_t *data, size_t len) {
|
||||
static const char *kHex = "0123456789abcdef";
|
||||
String out;
|
||||
@ -900,6 +940,30 @@ static String normalizeLoginValue(const String &value) {
|
||||
return out;
|
||||
}
|
||||
|
||||
static int hexNibbleValue(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
|
||||
if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
|
||||
return -1;
|
||||
}
|
||||
|
||||
static bool hex64ToBytes(const String &value, uint8_t out[32]) {
|
||||
String clean = value;
|
||||
clean.trim();
|
||||
if (clean.length() != 64) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < 32; ++i) {
|
||||
int hi = hexNibbleValue(clean.charAt((int)(i * 2)));
|
||||
int lo = hexNibbleValue(clean.charAt((int)(i * 2 + 1)));
|
||||
if (hi < 0 || lo < 0) {
|
||||
return false;
|
||||
}
|
||||
out[i] = (uint8_t)((hi << 4) | lo);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool isValidShineServerLoginValue(const String &value) {
|
||||
if (value.isEmpty() || value.length() > 20) {
|
||||
return false;
|
||||
@ -1587,6 +1651,34 @@ static bool jsonInt64Field(const String &json, const String &field, uint64_t &va
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool jsonSignedInt64Field(const String &json, const String &field, int64_t &valueOut) {
|
||||
String needle = "\"" + field + "\"";
|
||||
int keyPos = json.indexOf(needle);
|
||||
if (keyPos < 0) {
|
||||
return false;
|
||||
}
|
||||
int colon = json.indexOf(':', keyPos + needle.length());
|
||||
if (colon < 0) {
|
||||
return false;
|
||||
}
|
||||
int pos = colon + 1;
|
||||
while (pos < (int)json.length() && (json[pos] == ' ' || json[pos] == '\n' || json[pos] == '\r' || json[pos] == '\t')) {
|
||||
pos++;
|
||||
}
|
||||
int start = pos;
|
||||
if (pos < (int)json.length() && json[pos] == '-') {
|
||||
pos++;
|
||||
}
|
||||
while (pos < (int)json.length() && isDigit((unsigned char)json[pos])) {
|
||||
pos++;
|
||||
}
|
||||
if (pos == start || (pos == start + 1 && json[start] == '-')) {
|
||||
return false;
|
||||
}
|
||||
valueOut = strtoll(json.substring(start, pos).c_str(), nullptr, 10);
|
||||
return true;
|
||||
}
|
||||
|
||||
static String formatSolValue(uint64_t lamports) {
|
||||
uint64_t whole = lamports / 1000000000ULL;
|
||||
uint64_t frac = (lamports % 1000000000ULL) / 1000000ULL;
|
||||
@ -2695,6 +2787,195 @@ static bool sendWalletRpcResponse(const String &toLogin,
|
||||
return shineWsRequest(gShineWs, "CallSignalToSession", req, response, SHINE_RPC_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
static bool buildSendSignalSignatures(const String &toLogin,
|
||||
const String &targetMode,
|
||||
const String &targetSessionId,
|
||||
const String &signalType,
|
||||
const String &signalRequestId,
|
||||
const String &data,
|
||||
uint64_t timeMs,
|
||||
bool includeClientSignature,
|
||||
String &sessionSignatureB64Out,
|
||||
String &clientSignatureB64Out,
|
||||
String &errorOut) {
|
||||
sessionSignatureB64Out = "";
|
||||
clientSignatureB64Out = "";
|
||||
errorOut = "";
|
||||
|
||||
if (gLoginValue.isEmpty() || gShineSessionId.isEmpty()) {
|
||||
errorOut = "session_context_missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t dataHash32[32] = {};
|
||||
sha256calc(reinterpret_cast<const uint8_t *>(data.c_str()), data.length(), dataHash32);
|
||||
String dataSha256B64 = trimBase64Padding(bytesToBase64String(dataHash32, sizeof(dataHash32)));
|
||||
|
||||
uint8_t subSeed[32] = {};
|
||||
uint8_t subPub[32] = {};
|
||||
uint8_t subSec[64] = {};
|
||||
if (!deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) {
|
||||
errorOut = "homeserver_session_key_unavailable";
|
||||
return false;
|
||||
}
|
||||
|
||||
String sessionPreimage = String("SEND_SIGNAL_SESSION:")
|
||||
+ gLoginValue + ":"
|
||||
+ gShineSessionId + ":"
|
||||
+ toLogin + ":"
|
||||
+ targetMode + ":"
|
||||
+ targetSessionId + ":"
|
||||
+ signalType + ":"
|
||||
+ signalRequestId + ":"
|
||||
+ String((unsigned long long)timeMs) + ":"
|
||||
+ dataSha256B64;
|
||||
uint8_t sessionSignature[64] = {};
|
||||
std::vector<uint8_t> sessionMessage(reinterpret_cast<const uint8_t *>(sessionPreimage.c_str()),
|
||||
reinterpret_cast<const uint8_t *>(sessionPreimage.c_str()) + sessionPreimage.length());
|
||||
if (!signMessageEd25519(sessionMessage, subSec, sessionSignature)) {
|
||||
errorOut = "session_signal_sign_failed";
|
||||
return false;
|
||||
}
|
||||
sessionSignatureB64Out = bytesToBase64String(sessionSignature, sizeof(sessionSignature));
|
||||
|
||||
if (!includeClientSignature) {
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t clientSeed[32] = {};
|
||||
uint8_t clientPub[32] = {};
|
||||
uint8_t clientSec[64] = {};
|
||||
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, clientSeed, clientPub, clientSec)) {
|
||||
errorOut = "client_key_unavailable";
|
||||
return false;
|
||||
}
|
||||
|
||||
String clientPreimage = String("SEND_SIGNAL_CLIENT:")
|
||||
+ gLoginValue + ":"
|
||||
+ gShineSessionId + ":"
|
||||
+ toLogin + ":"
|
||||
+ targetMode + ":"
|
||||
+ targetSessionId + ":"
|
||||
+ signalType + ":"
|
||||
+ signalRequestId + ":"
|
||||
+ String((unsigned long long)timeMs) + ":"
|
||||
+ dataSha256B64;
|
||||
uint8_t clientSignature[64] = {};
|
||||
std::vector<uint8_t> clientMessage(reinterpret_cast<const uint8_t *>(clientPreimage.c_str()),
|
||||
reinterpret_cast<const uint8_t *>(clientPreimage.c_str()) + clientPreimage.length());
|
||||
if (!signMessageEd25519(clientMessage, clientSec, clientSignature)) {
|
||||
errorOut = "client_signal_sign_failed";
|
||||
return false;
|
||||
}
|
||||
clientSignatureB64Out = bytesToBase64String(clientSignature, sizeof(clientSignature));
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool sendSignalResponse(const String &toLogin,
|
||||
const String &targetSessionId,
|
||||
const String &signalType,
|
||||
const String &signalRequestId,
|
||||
const String &responseData) {
|
||||
String response;
|
||||
uint64_t timeMs = shineNowMs();
|
||||
String sessionSignatureB64;
|
||||
String clientSignatureB64;
|
||||
String error;
|
||||
if (!buildSendSignalSignatures(toLogin,
|
||||
"single_session",
|
||||
targetSessionId,
|
||||
signalType,
|
||||
signalRequestId,
|
||||
responseData,
|
||||
timeMs,
|
||||
false,
|
||||
sessionSignatureB64,
|
||||
clientSignatureB64,
|
||||
error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String req = String("{\"toLogin\":\"") + jsonEscape(toLogin)
|
||||
+ "\",\"targetMode\":\"single_session\""
|
||||
+ ",\"targetSessionId\":\"" + jsonEscape(targetSessionId)
|
||||
+ "\",\"signalType\":\"" + jsonEscape(signalType)
|
||||
+ "\",\"signalRequestId\":\"" + jsonEscape(signalRequestId)
|
||||
+ "\",\"data\":\"" + jsonEscape(responseData)
|
||||
+ "\",\"timeMs\":" + String((unsigned long long)timeMs)
|
||||
+ ",\"sessionSignatureB64\":\"" + jsonEscape(sessionSignatureB64)
|
||||
+ "\",\"clientSignatureB64\":\"" + jsonEscape(clientSignatureB64) + "\"}";
|
||||
return shineWsRequest(gShineWs, "SendSignal", req, response, SHINE_RPC_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
static void appendUint16BE(std::vector<uint8_t> &out, uint16_t value) {
|
||||
out.push_back((uint8_t)((value >> 8) & 0xFF));
|
||||
out.push_back((uint8_t)(value & 0xFF));
|
||||
}
|
||||
|
||||
static void appendInt32BE(std::vector<uint8_t> &out, int32_t value) {
|
||||
uint32_t v = (uint32_t)value;
|
||||
out.push_back((uint8_t)((v >> 24) & 0xFF));
|
||||
out.push_back((uint8_t)((v >> 16) & 0xFF));
|
||||
out.push_back((uint8_t)((v >> 8) & 0xFF));
|
||||
out.push_back((uint8_t)(v & 0xFF));
|
||||
}
|
||||
|
||||
static void appendInt64BE(std::vector<uint8_t> &out, int64_t value) {
|
||||
uint64_t v = (uint64_t)value;
|
||||
out.push_back((uint8_t)((v >> 56) & 0xFF));
|
||||
out.push_back((uint8_t)((v >> 48) & 0xFF));
|
||||
out.push_back((uint8_t)((v >> 40) & 0xFF));
|
||||
out.push_back((uint8_t)((v >> 32) & 0xFF));
|
||||
out.push_back((uint8_t)((v >> 24) & 0xFF));
|
||||
out.push_back((uint8_t)((v >> 16) & 0xFF));
|
||||
out.push_back((uint8_t)((v >> 8) & 0xFF));
|
||||
out.push_back((uint8_t)(v & 0xFF));
|
||||
}
|
||||
|
||||
static uint16_t readUint16BE(const uint8_t *data) {
|
||||
return (uint16_t)(((uint16_t)data[0] << 8) | (uint16_t)data[1]);
|
||||
}
|
||||
|
||||
static bool fetchRemoteAddBlockCursor(const String &login,
|
||||
String &blockchainNameOut,
|
||||
int32_t &lastBlockNumberOut,
|
||||
String &lastBlockHashOut,
|
||||
String &errorMessageOut,
|
||||
String &errorCodeOut) {
|
||||
const char *kRemoteZeroHash64 = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
blockchainNameOut = "";
|
||||
lastBlockNumberOut = -1;
|
||||
lastBlockHashOut = String(kRemoteZeroHash64);
|
||||
errorMessageOut = "";
|
||||
errorCodeOut = "";
|
||||
|
||||
String getUserReq = String("{\"login\":\"") + jsonEscape(login) + "\"}";
|
||||
String getUserResp;
|
||||
bool ok = shineWsRequest(gShineWs, "GetUser", getUserReq, getUserResp, SHINE_RPC_TIMEOUT_MS);
|
||||
uint64_t statusCode = 0;
|
||||
jsonInt64Field(getUserResp, "status", statusCode);
|
||||
if (!ok || statusCode != 200) {
|
||||
jsonStringField(getUserResp, "message", errorMessageOut);
|
||||
jsonStringField(getUserResp, "code", errorCodeOut);
|
||||
if (errorCodeOut.isEmpty()) errorCodeOut = ok ? "getuser_rejected" : "getuser_request_failed";
|
||||
if (errorMessageOut.isEmpty()) errorMessageOut = ok ? "GetUser rejected by server" : "GetUser request failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t lastBlockNumberI64 = -1;
|
||||
jsonStringField(getUserResp, "blockchainName", blockchainNameOut);
|
||||
jsonStringField(getUserResp, "serverLastGlobalHash", lastBlockHashOut);
|
||||
if (!jsonSignedInt64Field(getUserResp, "serverLastGlobalNumber", lastBlockNumberI64)) {
|
||||
lastBlockNumberI64 = -1;
|
||||
}
|
||||
lastBlockNumberOut = (int32_t)lastBlockNumberI64;
|
||||
if (lastBlockHashOut.isEmpty()) {
|
||||
lastBlockHashOut = String(kRemoteZeroHash64);
|
||||
}
|
||||
lastBlockHashOut.toLowerCase();
|
||||
return true;
|
||||
}
|
||||
|
||||
static void queueWalletSignRequest(const PendingWalletRpcRequest &item,
|
||||
const String &requestId,
|
||||
const String &publicKeyBase58,
|
||||
@ -4075,6 +4356,7 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St
|
||||
return true;
|
||||
}
|
||||
queueIncomingWalletRpcRequest(frame);
|
||||
queueIncomingRemoteAddBlockRequest(frame);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -4121,6 +4403,50 @@ static bool queueIncomingWalletRpcRequest(const String &frame) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool queueIncomingRemoteAddBlockRequest(const String &frame) {
|
||||
if (frame.indexOf("\"op\":\"IncomingSignal\"") < 0 || frame.indexOf("\"event\":true") < 0) {
|
||||
return false;
|
||||
}
|
||||
int payloadPos = frame.indexOf("\"payload\":");
|
||||
if (payloadPos < 0) {
|
||||
return false;
|
||||
}
|
||||
int objectStart = frame.indexOf('{', payloadPos);
|
||||
if (objectStart < 0) {
|
||||
return false;
|
||||
}
|
||||
int objectEnd = -1;
|
||||
String payloadJson;
|
||||
if (!extractJsonObjectAt(frame, objectStart, objectEnd, payloadJson)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String signalType;
|
||||
if (!jsonStringField(payloadJson, "signalType", signalType)) {
|
||||
return false;
|
||||
}
|
||||
if (signalType != "remote_addblock_request") {
|
||||
return false;
|
||||
}
|
||||
|
||||
PendingRemoteAddBlockRequest item;
|
||||
jsonStringField(payloadJson, "fromLogin", item.fromLogin);
|
||||
jsonStringField(payloadJson, "fromSessionId", item.fromSessionId);
|
||||
jsonStringField(payloadJson, "signalRequestId", item.signalRequestId);
|
||||
jsonStringField(payloadJson, "data", item.data);
|
||||
jsonStringField(payloadJson, "sessionSignatureB64", item.sessionSignatureB64);
|
||||
jsonStringField(payloadJson, "clientSignatureB64", item.clientSignatureB64);
|
||||
uint64_t timeMs = 0;
|
||||
jsonInt64Field(payloadJson, "timeMs", timeMs);
|
||||
item.timeMs = timeMs;
|
||||
item.signalType = signalType;
|
||||
if (item.fromLogin.isEmpty() || item.fromSessionId.isEmpty() || item.signalRequestId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
gPendingRemoteAddBlockRequests.push_back(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void processPendingWalletRpcRequests() {
|
||||
if (gPendingWalletRpcRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
||||
return;
|
||||
@ -4180,6 +4506,158 @@ static void processPendingWalletRpcRequests() {
|
||||
}
|
||||
}
|
||||
|
||||
static void processPendingRemoteAddBlockRequests() {
|
||||
if (gPendingRemoteAddBlockRequests.empty() || !gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (!gPendingRemoteAddBlockRequests.empty()) {
|
||||
PendingRemoteAddBlockRequest item = gPendingRemoteAddBlockRequests.front();
|
||||
gPendingRemoteAddBlockRequests.erase(gPendingRemoteAddBlockRequests.begin());
|
||||
|
||||
String responseData;
|
||||
String blockchainName;
|
||||
String blockBodyB64;
|
||||
|
||||
if (item.fromLogin != gLoginValue) {
|
||||
responseData = String("{\"ok\":false,\"error\":\"forbidden_login\",\"errorMessage\":\"Signal login mismatch\",\"requestId\":\"")
|
||||
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
if (item.sessionSignatureB64.isEmpty() || item.clientSignatureB64.isEmpty()) {
|
||||
responseData = String("{\"ok\":false,\"error\":\"missing_required_signatures\",\"errorMessage\":\"Client and session signatures are required\",\"requestId\":\"")
|
||||
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
|
||||
jsonStringField(item.data, "blockchainName", blockchainName);
|
||||
jsonStringField(item.data, "blockBodyB64", blockBodyB64);
|
||||
if (blockchainName.isEmpty() || blockBodyB64.isEmpty()) {
|
||||
responseData = String("{\"ok\":false,\"error\":\"bad_request\",\"errorMessage\":\"Missing required AddBlock fields\",\"requestId\":\"")
|
||||
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> remoteBody;
|
||||
if (!base64DecodeStd(blockBodyB64, remoteBody) || remoteBody.size() < 6) {
|
||||
responseData = String("{\"ok\":false,\"error\":\"bad_block_body_base64\",\"errorMessage\":\"Invalid remote block body base64\",\"requestId\":\"")
|
||||
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint16_t msgType = readUint16BE(remoteBody.data());
|
||||
uint16_t msgSubType = readUint16BE(remoteBody.data() + 2);
|
||||
uint16_t msgVersion = readUint16BE(remoteBody.data() + 4);
|
||||
std::vector<uint8_t> bodyBytes(remoteBody.begin() + 6, remoteBody.end());
|
||||
|
||||
String resolvedBlockchainName;
|
||||
int32_t lastBlockNumber = -1;
|
||||
String prevBlockHash;
|
||||
String cursorError;
|
||||
String cursorErrorCode;
|
||||
if (!fetchRemoteAddBlockCursor(gLoginValue, resolvedBlockchainName, lastBlockNumber, prevBlockHash, cursorError, cursorErrorCode)) {
|
||||
responseData = String("{\"ok\":false,\"error\":\"") + jsonEscape(cursorErrorCode)
|
||||
+ "\",\"errorMessage\":\"" + jsonEscape(cursorError)
|
||||
+ "\",\"requestId\":\"" + jsonEscape(item.signalRequestId) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
if (resolvedBlockchainName.isEmpty()) {
|
||||
resolvedBlockchainName = blockchainName;
|
||||
}
|
||||
int32_t nextBlockNumber = lastBlockNumber + 1;
|
||||
String cleanPrevHash = prevBlockHash.isEmpty()
|
||||
? String("0000000000000000000000000000000000000000000000000000000000000000")
|
||||
: prevBlockHash;
|
||||
uint8_t prevHash32[32] = {};
|
||||
if (!hex64ToBytes(cleanPrevHash, prevHash32)) {
|
||||
responseData = String("{\"ok\":false,\"error\":\"bad_prev_hash\",\"errorMessage\":\"Invalid previous block hash from GetUser\",\"requestId\":\"")
|
||||
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> preimage;
|
||||
preimage.reserve(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.size());
|
||||
appendUint16BE(preimage, 0);
|
||||
preimage.insert(preimage.end(), prevHash32, prevHash32 + 32);
|
||||
int32_t blockSize = (int32_t)(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.size());
|
||||
appendInt32BE(preimage, blockSize);
|
||||
appendInt32BE(preimage, nextBlockNumber);
|
||||
appendInt64BE(preimage, (int64_t)(shineNowMs() / 1000ULL));
|
||||
appendUint16BE(preimage, msgType);
|
||||
appendUint16BE(preimage, msgSubType);
|
||||
appendUint16BE(preimage, msgVersion);
|
||||
preimage.insert(preimage.end(), bodyBytes.begin(), bodyBytes.end());
|
||||
|
||||
uint8_t blockchainSeed[32] = {};
|
||||
uint8_t blockchainPub[32] = {};
|
||||
uint8_t blockchainSec[64] = {};
|
||||
if (!deriveSeedKeypairFromBase58(gBlockchainPrivB58, blockchainSeed, blockchainPub, blockchainSec)) {
|
||||
responseData = String("{\"ok\":false,\"error\":\"blockchain_key_unavailable\",\"errorMessage\":\"Blockchain key is unavailable on homeserver\",\"requestId\":\"")
|
||||
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint8_t hash32[32] = {};
|
||||
sha256calc(preimage.data(), preimage.size(), hash32);
|
||||
uint8_t signature[64] = {};
|
||||
if (!signMessageEd25519(std::vector<uint8_t>(hash32, hash32 + 32), blockchainSec, signature)) {
|
||||
responseData = String("{\"ok\":false,\"error\":\"sign_failed\",\"errorMessage\":\"Failed to sign AddBlock hash\",\"requestId\":\"")
|
||||
+ jsonEscape(item.signalRequestId) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> fullBlock = preimage;
|
||||
fullBlock.push_back(0x01);
|
||||
fullBlock.push_back(0x00);
|
||||
fullBlock.insert(fullBlock.end(), signature, signature + 64);
|
||||
String addBlockReq = String("{\"blockchainName\":\"") + jsonEscape(resolvedBlockchainName)
|
||||
+ "\",\"blockNumber\":" + String((long long)nextBlockNumber)
|
||||
+ ",\"prevBlockHash\":\"" + jsonEscape(cleanPrevHash)
|
||||
+ "\",\"blockBytesB64\":\"" + jsonEscape(bytesToBase64String(fullBlock.data(), fullBlock.size())) + "\"}";
|
||||
String addBlockResp;
|
||||
bool addBlockOk = shineWsRequest(gShineWs, "AddBlock", addBlockReq, addBlockResp, SHINE_RPC_TIMEOUT_MS);
|
||||
uint64_t statusCode = 0;
|
||||
jsonInt64Field(addBlockResp, "status", statusCode);
|
||||
String serverLastHash;
|
||||
String errorCode;
|
||||
String errorMessage;
|
||||
uint64_t serverLastNumber = 0;
|
||||
jsonStringField(addBlockResp, "serverLastGlobalHash", serverLastHash);
|
||||
jsonStringField(addBlockResp, "code", errorCode);
|
||||
jsonStringField(addBlockResp, "message", errorMessage);
|
||||
jsonInt64Field(addBlockResp, "serverLastGlobalNumber", serverLastNumber);
|
||||
|
||||
if (!addBlockOk || statusCode != 200) {
|
||||
if (errorCode.isEmpty()) {
|
||||
errorCode = addBlockOk ? "addblock_rejected" : "addblock_request_failed";
|
||||
}
|
||||
if (errorMessage.isEmpty()) {
|
||||
errorMessage = addBlockOk ? "AddBlock rejected by server" : "AddBlock request failed";
|
||||
}
|
||||
responseData = String("{\"ok\":false,\"error\":\"") + jsonEscape(errorCode)
|
||||
+ "\",\"errorMessage\":\"" + jsonEscape(errorMessage)
|
||||
+ "\",\"requestId\":\"" + jsonEscape(item.signalRequestId)
|
||||
+ "\",\"serverLastGlobalNumber\":" + String((unsigned long long)serverLastNumber)
|
||||
+ ",\"serverLastGlobalHash\":\"" + jsonEscape(serverLastHash) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
continue;
|
||||
}
|
||||
|
||||
responseData = String("{\"ok\":true,\"requestId\":\"") + jsonEscape(item.signalRequestId)
|
||||
+ "\",\"serverLastGlobalNumber\":" + String((unsigned long long)serverLastNumber)
|
||||
+ ",\"serverLastGlobalHash\":\"" + jsonEscape(serverLastHash) + "\"}";
|
||||
sendSignalResponse(item.fromLogin, item.fromSessionId, "remote_addblock_result", item.signalRequestId, responseData);
|
||||
}
|
||||
}
|
||||
|
||||
static void pumpShineIncomingFrames(uint32_t maxFrames) {
|
||||
if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) {
|
||||
return;
|
||||
@ -4193,6 +4671,7 @@ static void pumpShineIncomingFrames(uint32_t maxFrames) {
|
||||
break;
|
||||
}
|
||||
queueIncomingWalletRpcRequest(frame);
|
||||
queueIncomingRemoteAddBlockRequest(frame);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4748,6 +5227,7 @@ static void manageShineConnection() {
|
||||
|
||||
pumpShineIncomingFrames();
|
||||
processPendingWalletRpcRequests();
|
||||
processPendingRemoteAddBlockRequests();
|
||||
}
|
||||
|
||||
static void upsertKnownWifi(const String &ssid, const String &password) {
|
||||
|
||||
@ -89,6 +89,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListConta
|
||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendSignal_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_ReceiveIncomingMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.Net_SendMessagePair_Handler;
|
||||
@ -97,6 +98,7 @@ import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckSessionDelivery_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallInviteBroadcast_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_CallSignalToSession_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||
@ -196,6 +198,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("AckSessionDelivery", new Net_AckSessionDelivery_Handler()),
|
||||
Map.entry("CallInviteBroadcast", new Net_CallInviteBroadcast_Handler()),
|
||||
Map.entry("CallSignalToSession", new Net_CallSignalToSession_Handler()),
|
||||
Map.entry("SendSignal", new Net_SendSignal_Handler()),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("Ping", new Net_Ping_Handler()),
|
||||
@ -274,6 +277,7 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("AckSessionDelivery", Net_AckSessionDelivery_Request.class),
|
||||
Map.entry("CallInviteBroadcast", Net_CallInviteBroadcast_Request.class),
|
||||
Map.entry("CallSignalToSession", Net_CallSignalToSession_Request.class),
|
||||
Map.entry("SendSignal", Net_SendSignal_Request.class),
|
||||
|
||||
// --- system ---
|
||||
Map.entry("Ping", Net_Ping_Request.class),
|
||||
|
||||
@ -0,0 +1,220 @@
|
||||
package server.logic.ws_protocol.JSON.messages;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.Base64Ws;
|
||||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendSignal_Response;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import server.logic.ws_protocol.JSON.utils.AuthKeyUtils;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.dao.SolanaUsersDAO;
|
||||
import shine.db.entities.ActiveSessionEntry;
|
||||
import shine.db.entities.SolanaUserEntry;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class Net_SendSignal_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_SendSignal_Handler.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final String TARGET_MODE_SINGLE = "single_session";
|
||||
private static final String TARGET_MODE_ALL = "all_sessions";
|
||||
private static final long ALLOWED_SKEW_MS = 30_000L;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
|
||||
Net_SendSignal_Request req = (Net_SendSignal_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getActiveSession() == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
|
||||
String fromLogin = safe(ctx.getLogin());
|
||||
String fromSessionId = safe(ctx.getSessionId());
|
||||
ActiveSessionEntry activeSession = ctx.getActiveSession();
|
||||
|
||||
String toRequest = safe(req.getToLogin());
|
||||
String targetMode = safe(req.getTargetMode()).toLowerCase();
|
||||
String targetSessionId = safe(req.getTargetSessionId());
|
||||
String signalType = safe(req.getSignalType());
|
||||
String signalRequestId = safe(req.getSignalRequestId());
|
||||
String data = req.getData() == null ? "" : req.getData();
|
||||
Long timeMs = req.getTimeMs();
|
||||
String sessionSignatureB64 = safe(req.getSessionSignatureB64());
|
||||
String clientSignatureB64 = safe(req.getClientSignatureB64());
|
||||
|
||||
if (toRequest.isBlank() || signalType.isBlank() || signalRequestId.isBlank() || timeMs == null || sessionSignatureB64.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin/targetMode/signalType/signalRequestId/timeMs/sessionSignatureB64 обязательны");
|
||||
}
|
||||
if (!TARGET_MODE_SINGLE.equals(targetMode) && !TARGET_MODE_ALL.equals(targetMode)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_TARGET_MODE", "Поддерживаются targetMode=single_session или all_sessions");
|
||||
}
|
||||
if (TARGET_MODE_SINGLE.equals(targetMode) && targetSessionId.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Для single_session нужен targetSessionId");
|
||||
}
|
||||
if (TARGET_MODE_ALL.equals(targetMode) && !targetSessionId.isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Для all_sessions нельзя передавать targetSessionId");
|
||||
}
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
if (Math.abs(nowMs - timeMs) > ALLOWED_SKEW_MS) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "TIME_SKEW", "Время клиента отличается от сервера более чем на 30 секунд");
|
||||
}
|
||||
|
||||
SolanaUserEntry senderUser = ctx.getSolanaUser();
|
||||
if (senderUser == null || senderUser.getClientKey() == null || senderUser.getClientKey().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.SERVER_DATA_ERROR, "NO_CLIENT_KEY", "Для пользователя не найден client key");
|
||||
}
|
||||
|
||||
SolanaUserEntry targetUser = SolanaUsersDAO.getInstance().getByLogin(toRequest);
|
||||
if (targetUser == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
|
||||
}
|
||||
String toLogin = safe(targetUser.getLogin());
|
||||
|
||||
String digestB64;
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
digestB64 = Base64Ws.encode(digest.digest(data.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
log.warn("SendSignal: failed to hash signal data (fromLogin={}, signalType={})", fromLogin, signalType, e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_DATA", "Не удалось обработать data");
|
||||
}
|
||||
|
||||
String sessionPreimage = buildSessionPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
||||
if (!verifySignature(activeSession.getSessionKey(), sessionPreimage, sessionSignatureB64, "sessionKey")) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_SESSION_SIGNATURE", "Некорректная подпись session key");
|
||||
}
|
||||
|
||||
if (!clientSignatureB64.isBlank()) {
|
||||
String clientPreimage = buildClientPreimage(fromLogin, fromSessionId, toLogin, targetMode, targetSessionId, signalType, signalRequestId, timeMs, digestB64);
|
||||
if (!verifySignature(senderUser.getClientKey(), clientPreimage, clientSignatureB64, "clientKey")) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_CLIENT_SIGNATURE", "Некорректная подпись client key");
|
||||
}
|
||||
}
|
||||
|
||||
List<ConnectionContext> targets = resolveTargets(targetMode, toLogin, targetSessionId);
|
||||
if (targets.isEmpty()) {
|
||||
String code = TARGET_MODE_SINGLE.equals(targetMode) ? "SESSION_NOT_FOUND" : "NO_TARGET_SESSIONS";
|
||||
String msg = TARGET_MODE_SINGLE.equals(targetMode) ? "Целевая сессия не найдена" : "Нет активных сессий для доставки сигнала";
|
||||
return NetExceptionResponseFactory.error(req, 404, code, msg);
|
||||
}
|
||||
|
||||
List<String> deliveredSessionIds = new ArrayList<>();
|
||||
for (ConnectionContext targetCtx : targets) {
|
||||
String eventId = server.logic.ws_protocol.JSON.utils.NetIdGenerator.eventId("evt");
|
||||
ObjectNode payload = MAPPER.createObjectNode();
|
||||
payload.put("eventId", eventId);
|
||||
payload.put("fromLogin", fromLogin);
|
||||
payload.put("fromSessionId", fromSessionId);
|
||||
payload.put("toLogin", toLogin);
|
||||
payload.put("targetMode", targetMode);
|
||||
payload.put("targetSessionId", safe(targetCtx.getSessionId()));
|
||||
payload.put("signalType", signalType);
|
||||
payload.put("signalRequestId", signalRequestId);
|
||||
payload.put("data", data);
|
||||
payload.put("timeMs", timeMs);
|
||||
payload.put("sessionSignatureB64", sessionSignatureB64);
|
||||
payload.put("clientSignatureB64", clientSignatureB64);
|
||||
payload.put("dataSha256B64", digestB64);
|
||||
if (WsEventSender.sendEvent(targetCtx, "IncomingSignal", eventId, payload)) {
|
||||
deliveredSessionIds.add(safe(targetCtx.getSessionId()));
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveredSessionIds.isEmpty()) {
|
||||
return NetExceptionResponseFactory.error(req, 404, "DELIVERY_FAILED", "Не удалось доставить сигнал ни в одну целевую сессию");
|
||||
}
|
||||
|
||||
Net_SendSignal_Response resp = new Net_SendSignal_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setDeliveredCount(deliveredSessionIds.size());
|
||||
resp.setDeliveredSessionIds(deliveredSessionIds);
|
||||
return resp;
|
||||
}
|
||||
|
||||
private static String buildSessionPreimage(String fromLogin,
|
||||
String fromSessionId,
|
||||
String toLogin,
|
||||
String targetMode,
|
||||
String targetSessionId,
|
||||
String signalType,
|
||||
String signalRequestId,
|
||||
long timeMs,
|
||||
String dataSha256B64) {
|
||||
return "SEND_SIGNAL_SESSION:"
|
||||
+ fromLogin + ":"
|
||||
+ fromSessionId + ":"
|
||||
+ toLogin + ":"
|
||||
+ targetMode + ":"
|
||||
+ targetSessionId + ":"
|
||||
+ signalType + ":"
|
||||
+ signalRequestId + ":"
|
||||
+ timeMs + ":"
|
||||
+ dataSha256B64;
|
||||
}
|
||||
|
||||
private static String buildClientPreimage(String fromLogin,
|
||||
String fromSessionId,
|
||||
String toLogin,
|
||||
String targetMode,
|
||||
String targetSessionId,
|
||||
String signalType,
|
||||
String signalRequestId,
|
||||
long timeMs,
|
||||
String dataSha256B64) {
|
||||
return "SEND_SIGNAL_CLIENT:"
|
||||
+ fromLogin + ":"
|
||||
+ fromSessionId + ":"
|
||||
+ toLogin + ":"
|
||||
+ targetMode + ":"
|
||||
+ targetSessionId + ":"
|
||||
+ signalType + ":"
|
||||
+ signalRequestId + ":"
|
||||
+ timeMs + ":"
|
||||
+ dataSha256B64;
|
||||
}
|
||||
|
||||
private static boolean verifySignature(String publicKeyValue, String preimage, String signatureB64, String fieldName) throws Exception {
|
||||
byte[] publicKey32 = AuthKeyUtils.parseEd25519PublicKey(publicKeyValue, fieldName);
|
||||
byte[] signature64 = Base64Ws.decodeLen(signatureB64, 64, "signatureB64");
|
||||
return Ed25519Util.verify(preimage.getBytes(StandardCharsets.UTF_8), signature64, publicKey32);
|
||||
}
|
||||
|
||||
private static List<ConnectionContext> resolveTargets(String targetMode, String toLogin, String targetSessionId) {
|
||||
List<ConnectionContext> targets = new ArrayList<>();
|
||||
if (TARGET_MODE_SINGLE.equals(targetMode)) {
|
||||
ConnectionContext targetCtx = ActiveConnectionsRegistry.getInstance().getBySessionId(targetSessionId);
|
||||
if (targetCtx != null && toLogin.equalsIgnoreCase(safe(targetCtx.getLogin()))) {
|
||||
targets.add(targetCtx);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
Set<ConnectionContext> onlineTargets = ActiveConnectionsRegistry.getInstance().getByLogin(toLogin);
|
||||
onlineTargets.stream()
|
||||
.filter(item -> item != null && item.getWsSession() != null && item.getWsSession().isOpen())
|
||||
.sorted(Comparator.comparing(ConnectionContext::getSessionId, Comparator.nullsLast(String::compareTo)))
|
||||
.forEach(targets::add);
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_SendSignal_Request extends Net_Request {
|
||||
private String toLogin;
|
||||
private String targetMode;
|
||||
private String targetSessionId;
|
||||
private String signalType;
|
||||
private String signalRequestId;
|
||||
private String data;
|
||||
private Long timeMs;
|
||||
private String sessionSignatureB64;
|
||||
private String clientSignatureB64;
|
||||
|
||||
public String getToLogin() {
|
||||
return toLogin;
|
||||
}
|
||||
|
||||
public void setToLogin(String toLogin) {
|
||||
this.toLogin = toLogin;
|
||||
}
|
||||
|
||||
public String getTargetMode() {
|
||||
return targetMode;
|
||||
}
|
||||
|
||||
public void setTargetMode(String targetMode) {
|
||||
this.targetMode = targetMode;
|
||||
}
|
||||
|
||||
public String getTargetSessionId() {
|
||||
return targetSessionId;
|
||||
}
|
||||
|
||||
public void setTargetSessionId(String targetSessionId) {
|
||||
this.targetSessionId = targetSessionId;
|
||||
}
|
||||
|
||||
public String getSignalType() {
|
||||
return signalType;
|
||||
}
|
||||
|
||||
public void setSignalType(String signalType) {
|
||||
this.signalType = signalType;
|
||||
}
|
||||
|
||||
public String getSignalRequestId() {
|
||||
return signalRequestId;
|
||||
}
|
||||
|
||||
public void setSignalRequestId(String signalRequestId) {
|
||||
this.signalRequestId = signalRequestId;
|
||||
}
|
||||
|
||||
public String getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(String data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public Long getTimeMs() {
|
||||
return timeMs;
|
||||
}
|
||||
|
||||
public void setTimeMs(Long timeMs) {
|
||||
this.timeMs = timeMs;
|
||||
}
|
||||
|
||||
public String getSessionSignatureB64() {
|
||||
return sessionSignatureB64;
|
||||
}
|
||||
|
||||
public void setSessionSignatureB64(String sessionSignatureB64) {
|
||||
this.sessionSignatureB64 = sessionSignatureB64;
|
||||
}
|
||||
|
||||
public String getClientSignatureB64() {
|
||||
return clientSignatureB64;
|
||||
}
|
||||
|
||||
public void setClientSignatureB64(String clientSignatureB64) {
|
||||
this.clientSignatureB64 = clientSignatureB64;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package server.logic.ws_protocol.JSON.messages.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_SendSignal_Response extends Net_Response {
|
||||
private int deliveredCount;
|
||||
private List<String> deliveredSessionIds = new ArrayList<>();
|
||||
|
||||
public int getDeliveredCount() {
|
||||
return deliveredCount;
|
||||
}
|
||||
|
||||
public void setDeliveredCount(int deliveredCount) {
|
||||
this.deliveredCount = deliveredCount;
|
||||
}
|
||||
|
||||
public List<String> getDeliveredSessionIds() {
|
||||
return deliveredSessionIds;
|
||||
}
|
||||
|
||||
public void setDeliveredSessionIds(List<String> deliveredSessionIds) {
|
||||
this.deliveredSessionIds = deliveredSessionIds;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
# Перенести старые сессионные сигналы на `SendSignal`
|
||||
|
||||
## Контекст
|
||||
|
||||
В проект добавлен новый общий межсессионный transport `SendSignal`.
|
||||
|
||||
Первое текущее применение:
|
||||
|
||||
- `remote AddBlock via homeserver session`
|
||||
|
||||
Старые сценарии пока оставлены на прежнем транспорте, чтобы не ломать уже работающий код.
|
||||
|
||||
## Что перенести позже
|
||||
|
||||
1. Звонковые сигналы, которые сейчас идут через `CallSignalToSession`.
|
||||
2. Старый wallet/ESP32 обмен, где технические команды всё ещё привязаны к call-like транспорту.
|
||||
3. Остальные доверенные межсессионные команды одного пользователя.
|
||||
|
||||
## Что важно учесть при переносе
|
||||
|
||||
- не ломать обратную совместимость работающих звонков;
|
||||
- сохранить текущую маршрутизацию по `sessionId`;
|
||||
- договориться о едином `signalType`;
|
||||
- отдельно описать миграцию клиентских обработчиков событий:
|
||||
- `IncomingCallSignal` -> `IncomingSignal`
|
||||
|
||||
## С какого сценария продолжать
|
||||
|
||||
Начинать перенос со звонков, но только после отдельной ручной проверки того, что `SendSignal` стабильно отработал на `remote AddBlock`.
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.282
|
||||
server.version=1.2.262
|
||||
client.version=1.2.286
|
||||
server.version=1.2.266
|
||||
|
||||
@ -50,11 +50,12 @@ import * as keyStorageView from './pages/key-storage-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202605300007';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||
import * as toolsSettingsView from './pages/tools-settings-view.js';
|
||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
||||
@ -101,6 +102,7 @@ const routes = {
|
||||
'developer-settings-view': developerSettingsView,
|
||||
'server-settings-view': serverSettingsView,
|
||||
'tools-settings-view': toolsSettingsView,
|
||||
'remote-addblock-session-view': remoteAddBlockSessionView,
|
||||
'device-view': deviceView,
|
||||
'connect-device-view': connectDeviceView,
|
||||
'device-pairing-view': clientPairingView,
|
||||
|
||||
@ -205,7 +205,7 @@ export function render({ navigate }) {
|
||||
<span class="field-label">Активные заявки</span>
|
||||
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
|
||||
</div>
|
||||
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</p>
|
||||
<p class="meta-muted">Показываются все активные заявки. Для wallet-заявки выпускается отдельная session-only сессия без передачи постоянных ключей.</p>
|
||||
<div class="stack" id="pairing-requests-list"></div>
|
||||
`;
|
||||
|
||||
|
||||
147
shine-UI/js/pages/remote-addblock-session-view.js
Normal file
147
shine-UI/js/pages/remote-addblock-session-view.js
Normal file
@ -0,0 +1,147 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
saveEntrySettings,
|
||||
setAuthError,
|
||||
setAuthInfo,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
} from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'remote-addblock-session-view', title: 'AddBlock через homeserver' };
|
||||
|
||||
const SESSION_TYPE_HOMESERVER = 100;
|
||||
|
||||
function formatSessionTime(ms) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function sessionLabel(session) {
|
||||
const title = String(session?.clientInfoFromClient || '').trim();
|
||||
if (title) return title;
|
||||
return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'AddBlock через homeserver',
|
||||
leftAction: { label: '←', onClick: () => navigate('settings-view') },
|
||||
}),
|
||||
);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.innerHTML = `
|
||||
<p class="meta-muted">Если на устройстве нет локального blockchain key, UI сможет отправлять AddBlock в выбранную homeserver-сессию. Homeserver подпишет блок своим blockchain key и сам отправит обычный AddBlock на сервер.</p>
|
||||
<button class="primary-btn" type="button" id="remote-addblock-refresh">Обновить список homeserver-сессий</button>
|
||||
<button class="ghost-btn" type="button" id="remote-addblock-clear">Сбросить выбор</button>
|
||||
`;
|
||||
|
||||
const listCard = document.createElement('div');
|
||||
listCard.className = 'card stack';
|
||||
|
||||
const buildList = () => {
|
||||
listCard.innerHTML = '';
|
||||
const selectedId = String(state.entrySettings.remoteAddBlockSessionId || '').trim();
|
||||
const sessions = (state.sessions || [])
|
||||
.filter((item) => Number(item?.sessionType || 0) === SESSION_TYPE_HOMESERVER)
|
||||
.sort((a, b) => Number(b?.lastAuthenticatedAtMs || 0) - Number(a?.lastAuthenticatedAtMs || 0));
|
||||
|
||||
if (sessions.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'meta-muted';
|
||||
empty.textContent = 'Активных homeserver-сессий не найдено.';
|
||||
listCard.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const sessionId = String(session?.sessionId || '').trim();
|
||||
const item = document.createElement('button');
|
||||
item.className = 'session-item';
|
||||
item.type = 'button';
|
||||
const isSelected = sessionId && sessionId === selectedId;
|
||||
item.innerHTML = `
|
||||
<div class="stack" style="gap:4px; text-align:left;">
|
||||
<strong>${sessionLabel(session)}</strong>
|
||||
<span class="meta-muted">${session.geo || 'unknown'} · ${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||
<span class="meta-muted">sessionId: ${sessionId || '-'}</span>
|
||||
<span class="meta-muted">status: ${session.onlineOnThisServer ? 'online' : 'offline on this server'}</span>
|
||||
${isSelected ? '<span class="session-current-badge">Выбрана для remote AddBlock</span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
item.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveEntrySettings({
|
||||
...state.entrySettings,
|
||||
remoteAddBlockSessionId: sessionId,
|
||||
});
|
||||
buildList();
|
||||
setAuthInfo('Homeserver-сессия для remote AddBlock сохранена.');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
listCard.append(item);
|
||||
});
|
||||
};
|
||||
|
||||
actions.querySelector('#remote-addblock-refresh')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await refreshSessions();
|
||||
buildList();
|
||||
setAuthInfo('Список homeserver-сессий обновлён.');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
actions.querySelector('#remote-addblock-clear')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await saveEntrySettings({
|
||||
...state.entrySettings,
|
||||
remoteAddBlockSessionId: '',
|
||||
});
|
||||
buildList();
|
||||
setAuthInfo('Выбор homeserver-сессии для remote AddBlock сброшен.');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
buildList();
|
||||
screen.append(actions, listCard);
|
||||
return screen;
|
||||
}
|
||||
@ -41,6 +41,7 @@ export function render({ navigate }) {
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<button class="text-btn" type="button" id="settings-device">Устройства</button>
|
||||
<button class="text-btn" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
|
||||
<button class="text-btn" type="button" id="settings-tools">Настройки инструментов ввода</button>
|
||||
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
|
||||
@ -48,6 +49,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
|
||||
card.querySelector('#settings-tools').addEventListener('click', () => navigate('tools-settings-view'));
|
||||
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
transferAr,
|
||||
} from '../services/arweave-wallet-service.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||
import {
|
||||
calcLimitTopupPriceLamports,
|
||||
getLimitStepBytes,
|
||||
@ -65,6 +66,312 @@ function sessionArgsOrThrow() {
|
||||
return { login, storagePwd };
|
||||
}
|
||||
|
||||
const SHINE_PAYMENTS_PROGRAM_ID = 'c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW';
|
||||
const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE';
|
||||
const SHINE_PAYMENTS_SEEDS = {
|
||||
config: 'shine_payments_config',
|
||||
coef: 'shine_payments_coef_limit',
|
||||
queues: 'shine_payments_queues',
|
||||
inflow: 'shine_payments_inflow_vault',
|
||||
q1: 'shine_payments_q1_ticket',
|
||||
q2: 'shine_payments_q2_ticket',
|
||||
q3: 'shine_payments_q3_ticket',
|
||||
};
|
||||
const COEF_SCALE_PPM = 1_000_000n;
|
||||
const LAMPORTS_PER_SOL = 1_000_000_000n;
|
||||
const SUPPORT_TICKET_HELP_TEXT = [
|
||||
'Билет привязан к очереди 1 и к адресу получателя, который вы укажете при покупке.',
|
||||
'Оплата считается в USD, а списание идет в SOL по курсу USDT/SOL, который проверяется в момент транзакции.',
|
||||
'Если курс уезжает слишком далеко, покупка отклоняется. Для интерфейса заложен допуск 3%.',
|
||||
'Когда текущий лимит очереди заполнится, откроется новый лимит с более низким коэффициентом.',
|
||||
'Номер билета нужно сохранить отдельно: именно по нему удобно отслеживать, когда он дойдет до выплаты.',
|
||||
].join('\n');
|
||||
|
||||
function utf8Bytes(text) {
|
||||
return new TextEncoder().encode(String(text || ''));
|
||||
}
|
||||
|
||||
function concatBytes(...parts) {
|
||||
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
parts.forEach((part) => {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function u64ToBytes(value) {
|
||||
const out = new Uint8Array(8);
|
||||
let current = BigInt(value || 0);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
out[i] = Number(current & 0xffn);
|
||||
current >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readU64(data, offset) {
|
||||
let value = 0n;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
value |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readI64(data, offset) {
|
||||
let value = readU64(data, offset);
|
||||
if (value > 0x7fffffffffffffffn) {
|
||||
value -= 0x10000000000000000n;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readI32(data, offset) {
|
||||
let value = Number(readU64(data, offset) & 0xffffffffn);
|
||||
if (value > 0x7fffffff) value -= 0x100000000;
|
||||
return value;
|
||||
}
|
||||
|
||||
function trimZeros(value) {
|
||||
return String(value || '')
|
||||
.replace(/(\.\d*?[1-9])0+$/u, '$1')
|
||||
.replace(/\.0+$/u, '')
|
||||
.replace(/\.$/u, '');
|
||||
}
|
||||
|
||||
function formatUsdCentsText(cents) {
|
||||
const value = Number(cents || 0n) / 100;
|
||||
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatPpmCoefText(coefPpm) {
|
||||
const value = Number(coefPpm || 0n) / Number(COEF_SCALE_PPM);
|
||||
return `${trimZeros(value.toFixed(6))}x`;
|
||||
}
|
||||
|
||||
function formatLamportsSolText(lamports, digits = 9) {
|
||||
const value = Number(lamports || 0n) / Number(LAMPORTS_PER_SOL);
|
||||
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function formatPythSolUsdText(pyth) {
|
||||
const value = Number(pyth?.priceNum || 0n) / Number(pyth?.priceDen || 1n) / 100;
|
||||
return trimZeros(value.toFixed(6));
|
||||
}
|
||||
|
||||
function formatQueuePrefix(queueId) {
|
||||
if (queueId === 2) return 'Q2';
|
||||
if (queueId === 3) return 'Q3';
|
||||
return 'Q1';
|
||||
}
|
||||
|
||||
function parsePaymentsCoef(data) {
|
||||
let offset = 0;
|
||||
const version = data[offset];
|
||||
offset += 1;
|
||||
const coefPpm = readU64(data, offset); offset += 8;
|
||||
const limitUsdCents = readU64(data, offset); offset += 8;
|
||||
const callRewardLamports = readU64(data, offset);
|
||||
return { version, coefPpm, limitUsdCents, callRewardLamports };
|
||||
}
|
||||
|
||||
function parsePaymentsQueues(data) {
|
||||
let offset = 0;
|
||||
const version = data[offset];
|
||||
offset += 1;
|
||||
const q1TicketsTotal = readU64(data, offset); offset += 8;
|
||||
const q1TicketsPaid = readU64(data, offset); offset += 8;
|
||||
const q1SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||
const q1SumPaidUsdCents = readU64(data, offset); offset += 8;
|
||||
const q2TicketsTotal = readU64(data, offset); offset += 8;
|
||||
const q2TicketsPaid = readU64(data, offset); offset += 8;
|
||||
const q2SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||
const q2SumPaidUsdCents = readU64(data, offset); offset += 8;
|
||||
const q3TicketsTotal = readU64(data, offset); offset += 8;
|
||||
const q3TicketsPaid = readU64(data, offset); offset += 8;
|
||||
const q3SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||
const q3SumPaidUsdCents = readU64(data, offset);
|
||||
return {
|
||||
version,
|
||||
q1TicketsTotal,
|
||||
q1TicketsPaid,
|
||||
q1SumTotalUsdCents,
|
||||
q1SumPaidUsdCents,
|
||||
q2TicketsTotal,
|
||||
q2TicketsPaid,
|
||||
q2SumTotalUsdCents,
|
||||
q2SumPaidUsdCents,
|
||||
q3TicketsTotal,
|
||||
q3TicketsPaid,
|
||||
q3SumTotalUsdCents,
|
||||
q3SumPaidUsdCents,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePaymentsTicket(data) {
|
||||
let offset = 0;
|
||||
const version = data[offset];
|
||||
offset += 1;
|
||||
const queueId = data[offset];
|
||||
offset += 1;
|
||||
const index = readU64(data, offset);
|
||||
offset += 8;
|
||||
const isPaid = Boolean(data[offset]);
|
||||
offset += 1;
|
||||
const solana = window.solanaWeb3;
|
||||
const recipientWallet = new solana.PublicKey(data.slice(offset, offset + 32)).toBase58();
|
||||
offset += 32;
|
||||
const payoutUsdCents = readU64(data, offset);
|
||||
offset += 8;
|
||||
const debtBeforeUsdCents = readU64(data, offset);
|
||||
return {
|
||||
version,
|
||||
queueId,
|
||||
index,
|
||||
isPaid,
|
||||
recipientWallet,
|
||||
payoutUsdCents,
|
||||
debtBeforeUsdCents,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSupportTicketInput(rawValue) {
|
||||
const clean = String(rawValue || '').trim();
|
||||
if (!clean) throw new Error('Введите номер билета.');
|
||||
if (/^\d+$/.test(clean)) {
|
||||
return { queueId: 1, index: BigInt(clean) };
|
||||
}
|
||||
const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.');
|
||||
}
|
||||
return {
|
||||
queueId: Number(match[1]),
|
||||
index: BigInt(match[2]),
|
||||
};
|
||||
}
|
||||
|
||||
async function deriveSupportRandomWallet(extraText) {
|
||||
const solana = await loadSolanaWeb3();
|
||||
if (!window.crypto?.getRandomValues || !window.crypto?.subtle) {
|
||||
throw new Error('Этот браузер не поддерживает безопасную генерацию ключей.');
|
||||
}
|
||||
const entropy = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(entropy);
|
||||
const payload = concatBytes(
|
||||
entropy,
|
||||
utf8Bytes(extraText || ''),
|
||||
utf8Bytes(new Date().toISOString()),
|
||||
utf8Bytes(String(Date.now())),
|
||||
utf8Bytes(String(performance?.now?.() || 0)),
|
||||
);
|
||||
const seed = new Uint8Array(await window.crypto.subtle.digest('SHA-256', payload));
|
||||
const keypair = solana.Keypair.fromSeed(seed);
|
||||
return {
|
||||
address: keypair.publicKey.toBase58(),
|
||||
privateKey32Base58: solana.bs58.encode(seed),
|
||||
keypair,
|
||||
generatedAt: new Date().toLocaleString('ru-RU'),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSupportPaymentsCore(endpoint) {
|
||||
const solana = await loadSolanaWeb3();
|
||||
const rpc = String(endpoint || '').trim();
|
||||
const connection = new solana.Connection(rpc, 'confirmed');
|
||||
const programId = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
|
||||
const oracleAccount = new solana.PublicKey(SHINE_PAYMENTS_ORACLE_ACCOUNT);
|
||||
const [configPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.config)], programId);
|
||||
const [coefPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.coef)], programId);
|
||||
const [queuesPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.queues)], programId);
|
||||
const [inflowPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.inflow)], programId);
|
||||
const [oracleAi, configAi, coefAi, queuesAi] = await Promise.all([
|
||||
connection.getAccountInfo(oracleAccount, 'confirmed'),
|
||||
connection.getAccountInfo(configPda, 'confirmed'),
|
||||
connection.getAccountInfo(coefPda, 'confirmed'),
|
||||
connection.getAccountInfo(queuesPda, 'confirmed'),
|
||||
]);
|
||||
if (!oracleAi) throw new Error('Не найден аккаунт оракула SOL/USD.');
|
||||
if (!configAi || !coefAi || !queuesAi) throw new Error('PDA программы оплаты ещё не инициализированы.');
|
||||
|
||||
const parsePrice = (data) => {
|
||||
const price = readI64(data, 73);
|
||||
const exponent = readI32(data, 89);
|
||||
const publishTime = readI64(data, 93);
|
||||
if (price <= 0n) throw new Error('Оракул вернул некорректную цену.');
|
||||
let num = price * 100n;
|
||||
let den = 1n;
|
||||
if (exponent >= 0) {
|
||||
num *= 10n ** BigInt(exponent);
|
||||
} else {
|
||||
den *= 10n ** BigInt(-exponent);
|
||||
}
|
||||
return { priceNum: num, priceDen: den, publishTime };
|
||||
};
|
||||
|
||||
let configOffset = 0;
|
||||
const configVersion = configAi.data[configOffset];
|
||||
configOffset += 1;
|
||||
const daoWallet = new solana.PublicKey(configAi.data.slice(configOffset, configOffset + 32)).toBase58();
|
||||
configOffset += 32;
|
||||
const inflowVault = new solana.PublicKey(configAi.data.slice(configOffset, configOffset + 32)).toBase58();
|
||||
|
||||
return {
|
||||
connection,
|
||||
programId,
|
||||
oracleAccount,
|
||||
configPda,
|
||||
coefPda,
|
||||
queuesPda,
|
||||
inflowPda,
|
||||
config: { version: configVersion, daoWallet, inflowVault },
|
||||
coef: parsePaymentsCoef(coefAi.data),
|
||||
queues: parsePaymentsQueues(queuesAi.data),
|
||||
pyth: parsePrice(oracleAi.data),
|
||||
};
|
||||
}
|
||||
|
||||
function queueStateView(queues, queueId) {
|
||||
if (queueId === 2) {
|
||||
return {
|
||||
ticketsTotal: queues.q2TicketsTotal,
|
||||
ticketsPaid: queues.q2TicketsPaid,
|
||||
sumTotalUsdCents: queues.q2SumTotalUsdCents,
|
||||
sumPaidUsdCents: queues.q2SumPaidUsdCents,
|
||||
};
|
||||
}
|
||||
if (queueId === 3) {
|
||||
return {
|
||||
ticketsTotal: queues.q3TicketsTotal,
|
||||
ticketsPaid: queues.q3TicketsPaid,
|
||||
sumTotalUsdCents: queues.q3SumTotalUsdCents,
|
||||
sumPaidUsdCents: queues.q3SumPaidUsdCents,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ticketsTotal: queues.q1TicketsTotal,
|
||||
ticketsPaid: queues.q1TicketsPaid,
|
||||
sumTotalUsdCents: queues.q1SumTotalUsdCents,
|
||||
sumPaidUsdCents: queues.q1SumPaidUsdCents,
|
||||
};
|
||||
}
|
||||
|
||||
function queueSeedFor(queueId) {
|
||||
if (queueId === 2) return SHINE_PAYMENTS_SEEDS.q2;
|
||||
if (queueId === 3) return SHINE_PAYMENTS_SEEDS.q3;
|
||||
return SHINE_PAYMENTS_SEEDS.q1;
|
||||
}
|
||||
|
||||
function ticketPdaFor(programId, queueId, index) {
|
||||
const solana = window.solanaWeb3;
|
||||
return solana.PublicKey.findProgramAddressSync(
|
||||
[utf8Bytes(queueSeedFor(queueId)), u64ToBytes(index)],
|
||||
programId,
|
||||
);
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
@ -99,6 +406,640 @@ export function render({ navigate }) {
|
||||
arweaveWalletCtx = null;
|
||||
}
|
||||
|
||||
function renderSupportHub() {
|
||||
activeModeToken += 1;
|
||||
clearArweaveSecretsInMemory();
|
||||
content.innerHTML = '';
|
||||
|
||||
const backBtn = createModeBackButton(renderWalletChoice);
|
||||
|
||||
const intro = document.createElement('div');
|
||||
intro.className = 'card stack';
|
||||
intro.innerHTML = `
|
||||
<h2 style="margin:0;">Поддержать проект Сияние</h2>
|
||||
<p class="meta-muted" style="margin:0; line-height:1.5;">
|
||||
Здесь можно купить билет, посмотреть очередь и увидеть, как устроена покупка.
|
||||
Оплата идет в SOL, а сумма считается по курсу USD/USDT на момент транзакции.
|
||||
Номер билета и секретный ключ кошелька нужно сохранить отдельно.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'stack';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="support-buy" style="width:100%;">Купить билет</button>
|
||||
<button class="primary-btn" type="button" id="support-queue" style="width:100%;">Посмотреть очередь</button>
|
||||
<button class="primary-btn" type="button" id="support-keygen" style="width:100%;">Сгенерировать новую пару ключей</button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#support-buy')?.addEventListener('click', () => {
|
||||
void renderSupportBuy();
|
||||
});
|
||||
actions.querySelector('#support-queue')?.addEventListener('click', () => {
|
||||
void renderSupportQueue();
|
||||
});
|
||||
actions.querySelector('#support-keygen')?.addEventListener('click', () => {
|
||||
void renderSupportKeygen();
|
||||
});
|
||||
|
||||
content.append(backBtn, intro, actions);
|
||||
setStatus('Выберите действие в разделе поддержки.');
|
||||
}
|
||||
|
||||
async function renderSupportHelp(backTarget = renderSupportBuy) {
|
||||
const modeToken = ++activeModeToken;
|
||||
clearArweaveSecretsInMemory();
|
||||
content.innerHTML = '';
|
||||
|
||||
const backBtn = createModeBackButton(backTarget);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<h2 style="margin:0;">Справка по покупке билета</h2>
|
||||
<p class="meta-muted" style="margin:0; white-space:pre-wrap; line-height:1.55;">${SUPPORT_TICKET_HELP_TEXT}</p>
|
||||
<p class="meta-muted" style="margin:0;">
|
||||
После покупки билет остается в очереди 1. Позже можно открыть экран просмотра и проверить, сколько билетов и сумм уже прошло перед ним.
|
||||
</p>
|
||||
`;
|
||||
|
||||
content.append(backBtn, card);
|
||||
if (modeToken === activeModeToken) setStatus('Открыта подробная справка.');
|
||||
}
|
||||
|
||||
async function renderSupportKeygen() {
|
||||
const modeToken = ++activeModeToken;
|
||||
clearArweaveSecretsInMemory();
|
||||
content.innerHTML = '';
|
||||
|
||||
const backBtn = createModeBackButton(renderSupportHub);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<h2 style="margin:0;">Сгенерировать новую пару ключей</h2>
|
||||
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||
Генерация использует безопасный рандом браузера, а ваш дополнительный текст и текущее время добавляются как примесь.
|
||||
Вводить текст необязательно: даже без него ключи остаются случайными.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const saltLabel = document.createElement('label');
|
||||
saltLabel.className = 'meta-muted';
|
||||
saltLabel.setAttribute('for', 'support-key-salt');
|
||||
saltLabel.textContent = 'Дополнительная соль (необязательно)';
|
||||
|
||||
const saltInput = document.createElement('textarea');
|
||||
saltInput.id = 'support-key-salt';
|
||||
saltInput.rows = 3;
|
||||
saltInput.placeholder = 'Можно оставить пустым или добавить любой текст как дополнительную примесь';
|
||||
saltInput.spellcheck = false;
|
||||
|
||||
const timeLabel = document.createElement('p');
|
||||
timeLabel.className = 'meta-muted';
|
||||
timeLabel.textContent = 'Время генерации появится после нажатия кнопки.';
|
||||
|
||||
const generatedPublicLabel = document.createElement('label');
|
||||
generatedPublicLabel.className = 'meta-muted';
|
||||
generatedPublicLabel.setAttribute('for', 'support-generated-public');
|
||||
generatedPublicLabel.textContent = 'Публичный ключ';
|
||||
|
||||
const generatedPublicInput = document.createElement('input');
|
||||
generatedPublicInput.id = 'support-generated-public';
|
||||
generatedPublicInput.type = 'text';
|
||||
generatedPublicInput.readOnly = true;
|
||||
generatedPublicInput.placeholder = 'Появится после генерации';
|
||||
|
||||
const generatedSecretLabel = document.createElement('label');
|
||||
generatedSecretLabel.className = 'meta-muted';
|
||||
generatedSecretLabel.setAttribute('for', 'support-generated-secret');
|
||||
generatedSecretLabel.textContent = 'Секретный ключ (Base58, показывается один раз)';
|
||||
|
||||
const generatedSecretInput = document.createElement('textarea');
|
||||
generatedSecretInput.id = 'support-generated-secret';
|
||||
generatedSecretInput.rows = 4;
|
||||
generatedSecretInput.readOnly = true;
|
||||
generatedSecretInput.placeholder = 'Появится после генерации';
|
||||
generatedSecretInput.spellcheck = false;
|
||||
|
||||
const generatedAddressNote = document.createElement('p');
|
||||
generatedAddressNote.className = 'meta-muted';
|
||||
generatedAddressNote.style.margin = '0';
|
||||
generatedAddressNote.textContent = 'Secret key не сохраняется на сервере и не пишется в localStorage.';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'stack';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="support-generate" style="width:100%;">Сгенерировать пару ключей</button>
|
||||
<div class="row">
|
||||
<button class="text-btn" type="button" id="support-copy-public" style="width:100%;">Копировать public key</button>
|
||||
<button class="text-btn" type="button" id="support-copy-secret" style="width:100%;">Копировать secret key</button>
|
||||
</div>
|
||||
<button class="ghost-btn" type="button" id="support-download" style="width:100%;">Скачать ключи</button>
|
||||
`;
|
||||
|
||||
const generateBtn = actions.querySelector('#support-generate');
|
||||
const copyPublicBtn = actions.querySelector('#support-copy-public');
|
||||
const copySecretBtn = actions.querySelector('#support-copy-secret');
|
||||
const downloadBtn = actions.querySelector('#support-download');
|
||||
let generatedPair = null;
|
||||
|
||||
const clearGenerated = () => {
|
||||
generatedPair = null;
|
||||
generatedPublicInput.value = '';
|
||||
generatedSecretInput.value = '';
|
||||
timeLabel.textContent = 'Время генерации появится после нажатия кнопки.';
|
||||
};
|
||||
|
||||
clearGenerated();
|
||||
|
||||
generateBtn?.addEventListener('click', async () => {
|
||||
generateBtn.disabled = true;
|
||||
try {
|
||||
generatedPair = await deriveSupportRandomWallet(saltInput.value);
|
||||
if (modeToken !== activeModeToken) return;
|
||||
generatedPublicInput.value = generatedPair.address;
|
||||
generatedSecretInput.value = generatedPair.privateKey32Base58;
|
||||
timeLabel.textContent = `Сгенерировано: ${generatedPair.generatedAt}. Использован безопасный рандом браузера, текст и время добавлены как примесь.`;
|
||||
setStatus('Новая пара ключей сгенерирована. Секретный ключ показан на экране один раз.');
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Не удалось сгенерировать пару ключей: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
generateBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
copyPublicBtn?.addEventListener('click', async () => {
|
||||
const value = String(generatedPublicInput.value || '').trim();
|
||||
if (!value) {
|
||||
setStatus('Сначала сгенерируйте пару ключей.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setStatus('Public key скопирован.');
|
||||
} catch {
|
||||
setStatus('Не удалось скопировать public key.');
|
||||
}
|
||||
});
|
||||
|
||||
copySecretBtn?.addEventListener('click', async () => {
|
||||
const value = String(generatedSecretInput.value || '').trim();
|
||||
if (!value) {
|
||||
setStatus('Сначала сгенерируйте пару ключей.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setStatus('Secret key скопирован.');
|
||||
} catch {
|
||||
setStatus('Не удалось скопировать secret key.');
|
||||
}
|
||||
});
|
||||
|
||||
downloadBtn?.addEventListener('click', () => {
|
||||
const publicKey = String(generatedPublicInput.value || '').trim();
|
||||
const secretKey = String(generatedSecretInput.value || '').trim();
|
||||
if (!publicKey || !secretKey) {
|
||||
setStatus('Сначала сгенерируйте пару ключей.');
|
||||
return;
|
||||
}
|
||||
const payload = [
|
||||
`Публичный ключ: ${publicKey}`,
|
||||
`Секретный ключ: ${secretKey}`,
|
||||
`Время: ${timeLabel.textContent || ''}`,
|
||||
].join('\n');
|
||||
const blob = new Blob([payload], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `shine-keypair-${Date.now()}.txt`;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
setStatus('Файл с ключами скачан.');
|
||||
});
|
||||
|
||||
content.append(
|
||||
backBtn,
|
||||
card,
|
||||
saltLabel,
|
||||
saltInput,
|
||||
timeLabel,
|
||||
generatedPublicLabel,
|
||||
generatedPublicInput,
|
||||
generatedSecretLabel,
|
||||
generatedSecretInput,
|
||||
generatedAddressNote,
|
||||
actions,
|
||||
);
|
||||
setStatus('Генератор ключей готов. Дополнительная соль необязательна.');
|
||||
}
|
||||
|
||||
async function renderSupportQueue() {
|
||||
const modeToken = ++activeModeToken;
|
||||
clearArweaveSecretsInMemory();
|
||||
content.innerHTML = '';
|
||||
|
||||
const backBtn = createModeBackButton(renderSupportHub);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<h2 style="margin:0;">Посмотреть очередь билета</h2>
|
||||
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||
Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате <code>2-15</code> или <code>3 8</code>.
|
||||
Экран покажет, сколько билетов перед ним уже стоит, сколько уже выплачено и кому принадлежит билет.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const inputLabel = document.createElement('label');
|
||||
inputLabel.className = 'meta-muted';
|
||||
inputLabel.setAttribute('for', 'support-ticket-query');
|
||||
inputLabel.textContent = 'Номер билета';
|
||||
|
||||
const queryInput = document.createElement('input');
|
||||
queryInput.id = 'support-ticket-query';
|
||||
queryInput.type = 'text';
|
||||
queryInput.placeholder = 'Например: 12, 2-5 или 3 8';
|
||||
queryInput.autocomplete = 'off';
|
||||
queryInput.spellcheck = false;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'row';
|
||||
actions.innerHTML = `
|
||||
<button class="primary-btn" type="button" id="support-ticket-load" style="width:100%;">Показать билет</button>
|
||||
<button class="ghost-btn" type="button" id="support-ticket-reset" style="width:100%;">Сбросить</button>
|
||||
`;
|
||||
|
||||
const result = document.createElement('div');
|
||||
result.className = 'card stack';
|
||||
result.innerHTML = `
|
||||
<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>
|
||||
`;
|
||||
|
||||
let lastCoreState = null;
|
||||
|
||||
const renderTicketInfo = async () => {
|
||||
const raw = String(queryInput.value || '').trim();
|
||||
if (!raw) {
|
||||
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета.</p>`;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = parseSupportTicketInput(raw);
|
||||
const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||
lastCoreState = core;
|
||||
const solana = await loadSolanaWeb3();
|
||||
const ticketPdaSeed = queueSeedFor(parsed.queueId);
|
||||
const ticketIndexBytes = u64ToBytes(parsed.index);
|
||||
const [ticketPda] = solana.PublicKey.findProgramAddressSync(
|
||||
[utf8Bytes(ticketPdaSeed), ticketIndexBytes],
|
||||
core.programId,
|
||||
);
|
||||
const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed');
|
||||
if (!ticketInfo) {
|
||||
result.innerHTML = `
|
||||
<p class="meta-muted" style="margin:0;">Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const ticket = parsePaymentsTicket(ticketInfo.data);
|
||||
const queueState = queueStateView(core.queues, parsed.queueId);
|
||||
const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n;
|
||||
const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore;
|
||||
const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n;
|
||||
result.innerHTML = `
|
||||
<div><b>Билет:</b> ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}</div>
|
||||
<div><b>Статус:</b> ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}</div>
|
||||
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
|
||||
<div><b>До него в очереди:</b> ${ticketsBefore.toString()} билетов</div>
|
||||
<div><b>Из них уже выплачено:</b> ${paidBefore.toString()} билетов</div>
|
||||
<div><b>Ещё осталось до него:</b> ${remainingBefore.toString()} билетов</div>
|
||||
<div><b>Уже выплачено по сумме в очереди:</b> ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD</div>
|
||||
<div><b>Сумма этого билета:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
|
||||
<div><b>Накоплено до этого билета:</b> ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него</div>
|
||||
`;
|
||||
setStatus(`Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`);
|
||||
} catch (error) {
|
||||
result.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить билет')}</p>`;
|
||||
setStatus(`Не удалось посмотреть билет: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
};
|
||||
|
||||
actions.querySelector('#support-ticket-load')?.addEventListener('click', () => {
|
||||
void renderTicketInfo();
|
||||
});
|
||||
actions.querySelector('#support-ticket-reset')?.addEventListener('click', () => {
|
||||
queryInput.value = '';
|
||||
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
|
||||
setStatus('Поле билета очищено.');
|
||||
});
|
||||
|
||||
content.append(backBtn, card, inputLabel, queryInput, actions, result);
|
||||
setStatus('Просмотр билета готов. Введите номер в нужном формате.');
|
||||
}
|
||||
|
||||
async function renderSupportBuy() {
|
||||
const modeToken = ++activeModeToken;
|
||||
clearArweaveSecretsInMemory();
|
||||
content.innerHTML = '';
|
||||
|
||||
const backBtn = createModeBackButton(renderSupportHub);
|
||||
const helpCard = document.createElement('div');
|
||||
helpCard.className = 'card stack';
|
||||
helpCard.innerHTML = `
|
||||
<h2 style="margin:0;">Купить билет</h2>
|
||||
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||
Вы покупаете только билет очереди 1. Сумма задается в USD, а оплата уходит в SOL по курсу USDT/SOL на момент транзакции.
|
||||
Покупка подписывается вашим client key. Интерфейс проверяет допуск по курсу до 3 процентов и не дает выходить за текущий лимит по коэффициенту.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const stateCard = document.createElement('div');
|
||||
stateCard.className = 'card stack';
|
||||
stateCard.innerHTML = `
|
||||
<p class="meta-muted" style="margin:0;">Текущее состояние покупки загружается...</p>
|
||||
`;
|
||||
|
||||
const amountLabel = document.createElement('label');
|
||||
amountLabel.className = 'meta-muted';
|
||||
amountLabel.setAttribute('for', 'support-buy-amount');
|
||||
amountLabel.textContent = 'Сумма билета в долларах';
|
||||
|
||||
const amountInput = document.createElement('input');
|
||||
amountInput.id = 'support-buy-amount';
|
||||
amountInput.type = 'text';
|
||||
amountInput.value = '20';
|
||||
amountInput.inputMode = 'decimal';
|
||||
amountInput.autocomplete = 'off';
|
||||
|
||||
const recipientWrap = document.createElement('div');
|
||||
recipientWrap.className = 'stack';
|
||||
const recipientInput = document.createElement('input');
|
||||
recipientInput.id = 'support-buy-recipient';
|
||||
recipientInput.type = 'text';
|
||||
recipientInput.placeholder = 'Можно оставить пустым';
|
||||
recipientInput.autocomplete = 'off';
|
||||
recipientInput.spellcheck = false;
|
||||
const recipientLabel = document.createElement('label');
|
||||
recipientLabel.className = 'meta-muted';
|
||||
recipientLabel.setAttribute('for', 'support-buy-recipient');
|
||||
recipientLabel.textContent = 'Адрес кошелька для билета и выплаты';
|
||||
recipientWrap.append(recipientLabel, recipientInput);
|
||||
|
||||
const sameWalletRow = document.createElement('label');
|
||||
sameWalletRow.className = 'row';
|
||||
sameWalletRow.style.gap = '10px';
|
||||
sameWalletRow.style.alignItems = 'center';
|
||||
sameWalletRow.innerHTML = `
|
||||
<input type="checkbox" id="support-buy-same-wallet" checked />
|
||||
<span class="meta-muted">На тот же кошелёк, с которого покупаем</span>
|
||||
`;
|
||||
|
||||
const quoteCard = document.createElement('div');
|
||||
quoteCard.className = 'card stack';
|
||||
quoteCard.innerHTML = `<p class="meta-muted" style="margin:0;">Расчет появится после загрузки курса.</p>`;
|
||||
|
||||
const purchaseResultCard = document.createElement('div');
|
||||
purchaseResultCard.className = 'card stack';
|
||||
purchaseResultCard.innerHTML = `
|
||||
<p class="meta-muted" style="margin:0;">После покупки здесь появится номер билета и краткий итог.</p>
|
||||
`;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'stack';
|
||||
actions.innerHTML = `
|
||||
<div class="row">
|
||||
<button class="primary-btn" type="button" id="support-buy-submit" style="width:100%;">Купить</button>
|
||||
<button class="ghost-btn" type="button" id="support-buy-refresh" style="width:100%;">Обновить условия</button>
|
||||
</div>
|
||||
<button class="text-btn" type="button" id="support-buy-help" style="width:100%;">Справка</button>
|
||||
`;
|
||||
|
||||
const buyBtn = actions.querySelector('#support-buy-submit');
|
||||
const refreshBtn = actions.querySelector('#support-buy-refresh');
|
||||
const helpBtn = actions.querySelector('#support-buy-help');
|
||||
const sameWalletCheckbox = sameWalletRow.querySelector('#support-buy-same-wallet');
|
||||
|
||||
let walletCtx = null;
|
||||
let walletAddress = '';
|
||||
let currentCore = null;
|
||||
|
||||
const setRecipientFromWallet = () => {
|
||||
if (sameWalletCheckbox?.checked && walletAddress) {
|
||||
recipientInput.value = walletAddress;
|
||||
recipientInput.disabled = true;
|
||||
} else {
|
||||
recipientInput.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateQuote = () => {
|
||||
if (!currentCore) return;
|
||||
const queue = queueStateView(currentCore.queues, 1);
|
||||
const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents
|
||||
? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents
|
||||
: 0n;
|
||||
const currentCoef = formatPpmCoefText(currentCore.coef.coefPpm);
|
||||
const usdRaw = String(amountInput.value || '').trim().replace(',', '.');
|
||||
let amountUsdCents = 0n;
|
||||
let amountSol = '—';
|
||||
let maxPaySol = '—';
|
||||
let canBuy = true;
|
||||
try {
|
||||
const asNumber = Number(usdRaw);
|
||||
if (!Number.isFinite(asNumber) || asNumber <= 0) throw new Error('bad amount');
|
||||
amountUsdCents = BigInt(Math.round(asNumber * 100));
|
||||
const payLamports = (amountUsdCents * LAMPORTS_PER_SOL * currentCore.pyth.priceDen + currentCore.pyth.priceNum - 1n) / currentCore.pyth.priceNum;
|
||||
const maxPayLamports = (payLamports * 103n + 99n) / 100n;
|
||||
amountSol = formatLamportsSolText(payLamports, 9);
|
||||
maxPaySol = formatLamportsSolText(maxPayLamports, 9);
|
||||
if (amountUsdCents > remainingUsdCents) canBuy = false;
|
||||
} catch {
|
||||
canBuy = false;
|
||||
}
|
||||
|
||||
quoteCard.innerHTML = `
|
||||
<div><b>Коэффициент сейчас:</b> ${currentCoef}</div>
|
||||
<div><b>В очереди перед вами уже куплено:</b> ${formatUsdCentsText(queue.sumTotalUsdCents)} USD</div>
|
||||
<div><b>Осталось до лимита текущего коэффициента:</b> ${formatUsdCentsText(remainingUsdCents)} USD</div>
|
||||
<div><b>Следующий билет:</b> №${(queue.ticketsTotal + 1n).toString()}</div>
|
||||
<div><b>Примерная цена:</b> ${amountSol} SOL</div>
|
||||
<div><b>Максимум при допуске 3%:</b> ${maxPaySol} SOL</div>
|
||||
<div class="${canBuy ? 'ok' : 'warn'}">${canBuy ? 'Покупка укладывается в текущий лимит.' : 'Сумма сейчас не укладывается в текущий лимит или введена некорректно.'}</div>
|
||||
<div class="meta-muted" style="margin:0; white-space:pre-wrap; line-height:1.5;">
|
||||
Если текущий лимит заполнится, откроется новый лимит с более низким коэффициентом.
|
||||
Покупка идет в SOL, но считается по курсу USD/USDT на момент транзакции.
|
||||
</div>
|
||||
`;
|
||||
const hasRecipient = sameWalletCheckbox?.checked
|
||||
? Boolean(walletAddress)
|
||||
: Boolean(String(recipientInput.value || '').trim());
|
||||
buyBtn.disabled = !canBuy || !hasRecipient;
|
||||
};
|
||||
|
||||
const refreshCore = async () => {
|
||||
refreshBtn.disabled = true;
|
||||
try {
|
||||
currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||
if (modeToken !== activeModeToken) return;
|
||||
const queue = queueStateView(currentCore.queues, 1);
|
||||
const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents
|
||||
? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents
|
||||
: 0n;
|
||||
stateCard.innerHTML = `
|
||||
<div><b>Коэффициент:</b> ${formatPpmCoefText(currentCore.coef.coefPpm)}</div>
|
||||
<div><b>Лимит текущего коэффициента:</b> ${formatUsdCentsText(currentCore.coef.limitUsdCents)} USD</div>
|
||||
<div><b>Уже куплено в очереди 1:</b> ${formatUsdCentsText(queue.sumTotalUsdCents)} USD</div>
|
||||
<div><b>Осталось купить по текущему коэффициенту:</b> ${formatUsdCentsText(remainingUsdCents)} USD</div>
|
||||
<div><b>Сколько билетов уже в очереди:</b> ${queue.ticketsTotal.toString()}</div>
|
||||
<div><b>Следующий билет:</b> №${(queue.ticketsTotal + 1n).toString()}</div>
|
||||
<div><b>Курс:</b> 1 SOL = ${formatPythSolUsdText(currentCore.pyth)} USD</div>
|
||||
<div class="meta-muted" style="margin:0; line-height:1.5;">
|
||||
После заполнения лимита будет новый лимит, но уже с более низким коэффициентом.
|
||||
</div>
|
||||
`;
|
||||
setRecipientFromWallet();
|
||||
updateQuote();
|
||||
setStatus('Условия покупки обновлены.');
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
stateCard.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить состояние')}</p>`;
|
||||
quoteCard.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось рассчитать цену')}</p>`;
|
||||
setStatus(`Не удалось загрузить условия покупки: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
refreshBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
amountInput.addEventListener('input', updateQuote);
|
||||
recipientInput.addEventListener('input', updateQuote);
|
||||
sameWalletCheckbox?.addEventListener('change', () => {
|
||||
setRecipientFromWallet();
|
||||
updateQuote();
|
||||
});
|
||||
|
||||
helpBtn?.addEventListener('click', () => {
|
||||
void renderSupportHelp(renderSupportBuy);
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener('click', () => {
|
||||
void refreshCore();
|
||||
});
|
||||
|
||||
buyBtn?.addEventListener('click', async () => {
|
||||
buyBtn.disabled = true;
|
||||
try {
|
||||
if (!currentCore) {
|
||||
currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||
}
|
||||
if (!walletCtx) {
|
||||
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||
walletAddress = walletCtx.address;
|
||||
}
|
||||
setRecipientFromWallet();
|
||||
const queue = queueStateView(currentCore.queues, 1);
|
||||
const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents
|
||||
? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents
|
||||
: 0n;
|
||||
const amountUsd = String(amountInput.value || '').trim().replace(',', '.');
|
||||
const amountUsdNumber = Number(amountUsd);
|
||||
if (!Number.isFinite(amountUsdNumber) || amountUsdNumber <= 0) {
|
||||
throw new Error('Введите корректную сумму в долларах.');
|
||||
}
|
||||
const amountUsdCents = BigInt(Math.round(amountUsdNumber * 100));
|
||||
if (amountUsdCents > remainingUsdCents) {
|
||||
throw new Error('Сумма превышает остаток текущего лимита. Уменьшите сумму или дождитесь нового лимита.');
|
||||
}
|
||||
const recipientWallet = String(recipientInput.value || '').trim() || walletAddress;
|
||||
if (!recipientWallet) throw new Error('Не указан кошелёк получателя.');
|
||||
const solana = await loadSolanaWeb3();
|
||||
const recipientPubkey = new solana.PublicKey(recipientWallet);
|
||||
const payLamports = (amountUsdCents * LAMPORTS_PER_SOL * currentCore.pyth.priceDen + currentCore.pyth.priceNum - 1n) / currentCore.pyth.priceNum;
|
||||
const maxPayLamports = (payLamports * 103n + 99n) / 100n;
|
||||
const nextIndex = queue.ticketsTotal + 1n;
|
||||
const [ticketPda] = solana.PublicKey.findProgramAddressSync(
|
||||
[utf8Bytes(SHINE_PAYMENTS_SEEDS.q1), u64ToBytes(nextIndex)],
|
||||
currentCore.programId,
|
||||
);
|
||||
const data = concatBytes(
|
||||
new Uint8Array([5]),
|
||||
u64ToBytes(amountUsdCents),
|
||||
u64ToBytes(maxPayLamports),
|
||||
recipientPubkey.toBytes(),
|
||||
);
|
||||
const ix = new solana.TransactionInstruction({
|
||||
programId: currentCore.programId,
|
||||
keys: [
|
||||
{ pubkey: walletCtx.keypair.publicKey, isSigner: true, isWritable: true },
|
||||
{ pubkey: currentCore.configPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: currentCore.coefPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: currentCore.queuesPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: ticketPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: new solana.PublicKey(currentCore.config.daoWallet), isSigner: false, isWritable: true },
|
||||
{ pubkey: currentCore.oracleAccount, isSigner: false, isWritable: false },
|
||||
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||
],
|
||||
data,
|
||||
});
|
||||
const connection = new solana.Connection(String(state.entrySettings.solanaServer || '').trim(), 'confirmed');
|
||||
const tx = new solana.Transaction().add(ix);
|
||||
tx.feePayer = walletCtx.keypair.publicKey;
|
||||
const bh = await connection.getLatestBlockhash('confirmed');
|
||||
tx.recentBlockhash = bh.blockhash;
|
||||
tx.partialSign(walletCtx.keypair);
|
||||
const signature = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false });
|
||||
await connection.confirmTransaction(
|
||||
{ signature, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight },
|
||||
'confirmed',
|
||||
);
|
||||
const ticketInfo = await connection.getAccountInfo(ticketPda, 'confirmed');
|
||||
const ticket = ticketInfo?.data
|
||||
? parsePaymentsTicket(ticketInfo.data)
|
||||
: { index: nextIndex, queueId: 1, recipientWallet, payoutUsdCents: amountUsdCents };
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()} куплен. Сохраните номер билета и secret key используемого кошелька.`);
|
||||
purchaseResultCard.innerHTML = `
|
||||
<div><b>Покупка завершена:</b> билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()}</div>
|
||||
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
|
||||
<div><b>Сумма билета:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
|
||||
<div><b>До него было в очереди:</b> ${ticket.index > 0n ? (ticket.index - 1n).toString() : '0'} билетов</div>
|
||||
<div><b>Транзакция:</b> <span style="word-break:break-all;">${signature}</span></div>
|
||||
`;
|
||||
await refreshCore();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Не удалось купить билет: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
buyBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
content.append(
|
||||
backBtn,
|
||||
helpCard,
|
||||
stateCard,
|
||||
amountLabel,
|
||||
amountInput,
|
||||
recipientWrap,
|
||||
sameWalletRow,
|
||||
quoteCard,
|
||||
purchaseResultCard,
|
||||
actions,
|
||||
);
|
||||
|
||||
if (!walletCtx) {
|
||||
try {
|
||||
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||
walletAddress = walletCtx.address;
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setRecipientFromWallet();
|
||||
} catch (error) {
|
||||
setStatus(`Не удалось загрузить client.key: ${error?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await refreshCore();
|
||||
}
|
||||
|
||||
function renderWalletChoice() {
|
||||
activeModeToken += 1;
|
||||
clearArweaveSecretsInMemory();
|
||||
@ -135,7 +1076,15 @@ export function render({ navigate }) {
|
||||
void renderShineBlockchainWallet();
|
||||
});
|
||||
|
||||
card.append(solanaBtn, arweaveBtn, shineBchBtn);
|
||||
const supportBtn = document.createElement('button');
|
||||
supportBtn.className = 'primary-btn';
|
||||
supportBtn.style.width = '100%';
|
||||
supportBtn.textContent = 'Поддержать проект Сияние';
|
||||
supportBtn.addEventListener('click', () => {
|
||||
void renderSupportHub();
|
||||
});
|
||||
|
||||
card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn);
|
||||
content.append(card);
|
||||
setStatus('Выберите тип кошелька.');
|
||||
}
|
||||
|
||||
@ -178,6 +178,7 @@ export function resolveToolbarActive(pageId) {
|
||||
pageId === 'developer-settings-view' ||
|
||||
pageId === 'server-settings-view' ||
|
||||
pageId === 'tools-settings-view' ||
|
||||
pageId === 'remote-addblock-session-view' ||
|
||||
pageId === 'device-view' ||
|
||||
pageId === 'connect-device-view' ||
|
||||
pageId === 'device-pairing-view' ||
|
||||
|
||||
@ -56,6 +56,11 @@ const CHANNEL_TYPE_GROUP = 200;
|
||||
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
|
||||
const SESSION_TYPE_CLIENT = 1;
|
||||
const SESSION_TYPE_WALLET = 50;
|
||||
const SESSION_TYPE_HOMESERVER = 100;
|
||||
const SIGNAL_TARGET_SINGLE = 'single_session';
|
||||
const SIGNAL_TARGET_ALL = 'all_sessions';
|
||||
const SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST = 'remote_addblock_request';
|
||||
const SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT = 'remote_addblock_result';
|
||||
|
||||
const CONNECTION_SUBTYPES = Object.freeze({
|
||||
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
|
||||
@ -108,6 +113,10 @@ function opError(op, response) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function createSignalRequestId(prefix = 'signal') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function makeClientInfo() {
|
||||
const ua = navigator.userAgent || 'unknown';
|
||||
return ua.slice(0, 50);
|
||||
@ -137,6 +146,11 @@ function normalizeHex32(value, fallback = ZERO64) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function sha256Base64FromText(text) {
|
||||
const digest = await sha256Bytes(utf8Bytes(String(text || '')));
|
||||
return bytesToBase64(digest).replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function concatBytes(...chunks) {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
@ -737,6 +751,16 @@ function buildBlockPreimage({ prevBlockHashHex, blockNumber, msgType, msgSubType
|
||||
);
|
||||
}
|
||||
|
||||
function buildRemoteBlockBodyBytes({ msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const body = bodyBytes || new Uint8Array(0);
|
||||
return concatBytes(
|
||||
int16Bytes(msgType),
|
||||
int16Bytes(msgSubType),
|
||||
int16Bytes(msgVersion),
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor(serverUrl) {
|
||||
this.serverUrl = normalizeServerUrl(serverUrl);
|
||||
@ -745,6 +769,9 @@ export class AuthService {
|
||||
this.writeLocks = new Map();
|
||||
this.passwordKeyBundleCache = new Map();
|
||||
this.passwordKeyBundleInFlight = new Map();
|
||||
this.currentLogin = '';
|
||||
this.currentSessionId = '';
|
||||
this.remoteAddBlockSessionId = '';
|
||||
}
|
||||
|
||||
async reconnect(serverUrl) {
|
||||
@ -757,6 +784,20 @@ export class AuthService {
|
||||
this.writeLocks.clear();
|
||||
}
|
||||
|
||||
setActiveSessionContext({ login = '', sessionId = '' } = {}) {
|
||||
this.currentLogin = String(login || '').trim();
|
||||
this.currentSessionId = String(sessionId || '').trim();
|
||||
}
|
||||
|
||||
clearActiveSessionContext() {
|
||||
this.currentLogin = '';
|
||||
this.currentSessionId = '';
|
||||
}
|
||||
|
||||
setRemoteAddBlockSessionId(sessionId = '') {
|
||||
this.remoteAddBlockSessionId = String(sessionId || '').trim();
|
||||
}
|
||||
|
||||
runWriteLocked(lockKey, runAction) {
|
||||
const key = String(lockKey || '').trim() || 'write';
|
||||
if (this.writeLocks.has(key)) return this.writeLocks.get(key);
|
||||
@ -1096,6 +1137,100 @@ export class AuthService {
|
||||
return response?.payload?.sessions || [];
|
||||
}
|
||||
|
||||
async waitForSignal({ signalType, signalRequestId, timeoutMs = 15000 }) {
|
||||
const cleanSignalType = String(signalType || '').trim();
|
||||
const cleanSignalRequestId = String(signalRequestId || '').trim();
|
||||
if (!cleanSignalType || !cleanSignalRequestId) {
|
||||
throw new Error('waitForSignal: не переданы signalType/signalRequestId');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let unsubscribe = () => {};
|
||||
const timer = window.setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Таймаут ожидания сигнала ${cleanSignalType}`));
|
||||
}, timeoutMs);
|
||||
|
||||
unsubscribe = this.onEvent('IncomingSignal', (evt) => {
|
||||
const payload = evt?.payload || {};
|
||||
if (String(payload?.signalType || '').trim() !== cleanSignalType) return;
|
||||
if (String(payload?.signalRequestId || '').trim() !== cleanSignalRequestId) return;
|
||||
window.clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignal({
|
||||
toLogin,
|
||||
targetMode = SIGNAL_TARGET_SINGLE,
|
||||
targetSessionId = '',
|
||||
signalType,
|
||||
signalRequestId,
|
||||
data = '',
|
||||
storagePwd = '',
|
||||
includeClientSignature = true,
|
||||
timeMs = Date.now(),
|
||||
} = {}) {
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
const cleanTargetMode = String(targetMode || '').trim();
|
||||
const cleanTargetSessionId = String(targetSessionId || '').trim();
|
||||
const cleanSignalType = String(signalType || '').trim();
|
||||
const cleanSignalRequestId = String(signalRequestId || '').trim();
|
||||
const cleanLogin = String(this.currentLogin || '').trim();
|
||||
const cleanSessionId = String(this.currentSessionId || '').trim();
|
||||
if (!cleanLogin || !cleanSessionId) throw new Error('SendSignal: нет активного login/sessionId');
|
||||
if (!cleanToLogin || !cleanSignalType || !cleanSignalRequestId) {
|
||||
throw new Error('SendSignal: не переданы toLogin/signalType/signalRequestId');
|
||||
}
|
||||
if (cleanTargetMode !== SIGNAL_TARGET_SINGLE && cleanTargetMode !== SIGNAL_TARGET_ALL) {
|
||||
throw new Error('SendSignal: bad targetMode');
|
||||
}
|
||||
if (cleanTargetMode === SIGNAL_TARGET_SINGLE && !cleanTargetSessionId) {
|
||||
throw new Error('SendSignal: targetSessionId обязателен для single_session');
|
||||
}
|
||||
|
||||
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
||||
if (!sessionMaterial?.sessionPrivPkcs8) {
|
||||
throw new Error('На устройстве нет сохранённого session key для SendSignal');
|
||||
}
|
||||
const sessionPrivateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
||||
|
||||
const dataText = typeof data === 'string' ? data : JSON.stringify(data || {});
|
||||
const dataSha256B64 = await sha256Base64FromText(dataText);
|
||||
|
||||
const sessionPreimage = `SEND_SIGNAL_SESSION:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
|
||||
const sessionSignatureB64 = await signBase64(sessionPrivateKey, sessionPreimage);
|
||||
|
||||
let clientSignatureB64 = '';
|
||||
if (includeClientSignature) {
|
||||
if (!storagePwd) throw new Error('SendSignal: нужен storagePwd для подписи client key');
|
||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const clientPrivatePkcs8 = String(secrets?.clientKey || '').trim();
|
||||
if (!clientPrivatePkcs8) {
|
||||
throw new Error('На устройстве нет сохранённого client key для SendSignal');
|
||||
}
|
||||
const clientPrivateKey = await importPkcs8Ed25519(clientPrivatePkcs8);
|
||||
const clientPreimage = `SEND_SIGNAL_CLIENT:${cleanLogin}:${cleanSessionId}:${cleanToLogin}:${cleanTargetMode}:${cleanTargetSessionId}:${cleanSignalType}:${cleanSignalRequestId}:${Number(timeMs)}:${dataSha256B64}`;
|
||||
clientSignatureB64 = await signBase64(clientPrivateKey, clientPreimage);
|
||||
}
|
||||
|
||||
const response = await this.ws.request('SendSignal', {
|
||||
toLogin: cleanToLogin,
|
||||
targetMode: cleanTargetMode,
|
||||
targetSessionId: cleanTargetMode === SIGNAL_TARGET_SINGLE ? cleanTargetSessionId : '',
|
||||
signalType: cleanSignalType,
|
||||
signalRequestId: cleanSignalRequestId,
|
||||
data: dataText,
|
||||
timeMs: Number(timeMs),
|
||||
sessionSignatureB64,
|
||||
clientSignatureB64,
|
||||
});
|
||||
if (response.status !== 200) throw opError('SendSignal', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async closeSession(sessionId) {
|
||||
const response = await this.ws.request('CloseActiveSession', { sessionId });
|
||||
if (response.status !== 200) throw opError('CloseActiveSession', response);
|
||||
@ -1219,56 +1354,174 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
|
||||
|
||||
const resolveFreshCursor = async () => {
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
return {
|
||||
blockchainName,
|
||||
cursor: {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash,
|
||||
},
|
||||
};
|
||||
async resolveFreshBlockchainCursor(login) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = normalizeHex32(user?.serverLastGlobalHash, ZERO64);
|
||||
return {
|
||||
blockchainName,
|
||||
cursor: {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash,
|
||||
},
|
||||
};
|
||||
const freshState = await resolveFreshCursor();
|
||||
const blockchainName = freshState.blockchainName;
|
||||
}
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('Missing saved blockchain private key on device');
|
||||
async submitPreparedAddBlock({ login, storagePwd, blockchainName, blockNumber, prevBlockHash, preimage }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanBlockchainName = String(blockchainName || '').trim();
|
||||
const cleanPrevBlockHash = normalizeHex32(prevBlockHash, ZERO_HASH_HEX);
|
||||
if (!cleanLogin || !cleanBlockchainName) throw new Error('submitPreparedAddBlock: missing login/blockchainName');
|
||||
if (!(preimage instanceof Uint8Array) || preimage.length === 0) {
|
||||
throw new Error('submitPreparedAddBlock: bad preimage');
|
||||
}
|
||||
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = String(savedKeys?.blockchainKey || '').trim();
|
||||
if (blockchainPrivatePkcs8) {
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
return this.ws.request('AddBlock', {
|
||||
blockchainName: cleanBlockchainName,
|
||||
blockNumber: Number(blockNumber),
|
||||
prevBlockHash: cleanPrevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
}
|
||||
|
||||
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
|
||||
if (!remoteSessionId) {
|
||||
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
|
||||
}
|
||||
|
||||
const signalRequestId = createSignalRequestId('remote-addblock');
|
||||
const responseWait = this.waitForSignal({
|
||||
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT,
|
||||
signalRequestId,
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
|
||||
const signalData = {
|
||||
operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
||||
signalRequestId,
|
||||
login: cleanLogin,
|
||||
blockchainName: cleanBlockchainName,
|
||||
blockNumber: Number(blockNumber),
|
||||
prevBlockHash: cleanPrevBlockHash,
|
||||
blockPreimageB64: bytesToBase64(preimage),
|
||||
};
|
||||
|
||||
await this.sendSignal({
|
||||
toLogin: cleanLogin,
|
||||
targetMode: SIGNAL_TARGET_SINGLE,
|
||||
targetSessionId: remoteSessionId,
|
||||
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
||||
signalRequestId,
|
||||
data: JSON.stringify(signalData),
|
||||
storagePwd,
|
||||
includeClientSignature: true,
|
||||
});
|
||||
|
||||
const signalPayload = await responseWait;
|
||||
let result = {};
|
||||
try {
|
||||
result = JSON.parse(String(signalPayload?.data || '{}'));
|
||||
} catch {
|
||||
throw new Error('Некорректный ответ remote AddBlock от homeserver');
|
||||
}
|
||||
if (!result?.ok) {
|
||||
throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed'));
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
payload: {
|
||||
serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? blockNumber),
|
||||
serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX),
|
||||
remote: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async submitRemoteAddBlockBody({ login, storagePwd, blockchainName, blockBodyBytes }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
const cleanBlockchainName = String(blockchainName || '').trim();
|
||||
if (!cleanLogin || !cleanBlockchainName) throw new Error('submitRemoteAddBlockBody: missing login/blockchainName');
|
||||
if (!(blockBodyBytes instanceof Uint8Array) || blockBodyBytes.length < 6) {
|
||||
throw new Error('submitRemoteAddBlockBody: bad blockBodyBytes');
|
||||
}
|
||||
|
||||
const remoteSessionId = String(this.remoteAddBlockSessionId || '').trim();
|
||||
if (!remoteSessionId) {
|
||||
throw new Error('На устройстве нет blockchain key и не выбрана homeserver-сессия для remote AddBlock');
|
||||
}
|
||||
|
||||
const signalRequestId = createSignalRequestId('remote-addblock');
|
||||
const responseWait = this.waitForSignal({
|
||||
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_RESULT,
|
||||
signalRequestId,
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
|
||||
const signalData = {
|
||||
operation: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
||||
signalRequestId,
|
||||
blockchainName: cleanBlockchainName,
|
||||
blockBodyB64: bytesToBase64(blockBodyBytes),
|
||||
};
|
||||
|
||||
await this.sendSignal({
|
||||
toLogin: cleanLogin,
|
||||
targetMode: SIGNAL_TARGET_SINGLE,
|
||||
targetSessionId: remoteSessionId,
|
||||
signalType: SIGNAL_TYPE_REMOTE_ADDBLOCK_REQUEST,
|
||||
signalRequestId,
|
||||
data: JSON.stringify(signalData),
|
||||
storagePwd,
|
||||
includeClientSignature: true,
|
||||
});
|
||||
|
||||
const signalPayload = await responseWait;
|
||||
let result = {};
|
||||
try {
|
||||
result = JSON.parse(String(signalPayload?.data || '{}'));
|
||||
} catch {
|
||||
throw new Error('Некорректный ответ remote AddBlock от homeserver');
|
||||
}
|
||||
if (!result?.ok) {
|
||||
throw new Error(String(result?.errorMessage || result?.error || 'remote_addblock_failed'));
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
payload: {
|
||||
serverLastGlobalNumber: Number(result?.serverLastGlobalNumber ?? -1),
|
||||
serverLastGlobalHash: String(result?.serverLastGlobalHash || ZERO_HASH_HEX),
|
||||
remote: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async runAddBlockWithRetry({ login, storagePwd, resolveFreshState, buildPreimage }) {
|
||||
let freshState = await resolveFreshState();
|
||||
let blockchainName = String(freshState?.blockchainName || '').trim();
|
||||
if (!blockchainName) throw new Error('runAddBlockWithRetry: blockchainName is empty');
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = normalizeHex32(cursor?.serverLastGlobalHash, ZERO64);
|
||||
const preimage = buildBlockPreimage({
|
||||
prevBlockHashHex: prevBlockHash,
|
||||
blockNumber,
|
||||
msgType,
|
||||
msgSubType,
|
||||
msgVersion,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
return this.ws.request('AddBlock', {
|
||||
const preimage = await buildPreimage({ blockNumber, prevBlockHash, blockchainName });
|
||||
return this.submitPreparedAddBlock({
|
||||
login,
|
||||
storagePwd,
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
preimage,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1281,13 +1534,53 @@ export class AuthService {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash.toLowerCase() };
|
||||
response = await tryAdd(cursor);
|
||||
} else {
|
||||
const refreshed = await resolveFreshCursor();
|
||||
cursor = refreshed.cursor;
|
||||
freshState = await resolveFreshState();
|
||||
blockchainName = String(freshState?.blockchainName || blockchainName).trim() || blockchainName;
|
||||
cursor = freshState.cursor;
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
return {
|
||||
response,
|
||||
blockchainName,
|
||||
};
|
||||
}
|
||||
|
||||
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||
const cleanLogin = (login || '').trim();
|
||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||
if (!storagePwd) throw new Error('Missing storagePwd for AddBlock signing');
|
||||
|
||||
const keyBundle = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = String(keyBundle?.blockchainKey || '').trim();
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
if (!blockchainName) throw new Error('Не удалось определить blockchainName для remote AddBlock');
|
||||
const response = await this.submitRemoteAddBlockBody({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
blockchainName,
|
||||
blockBodyBytes: buildRemoteBlockBodyBytes({ msgType, msgSubType, msgVersion, bodyBytes }),
|
||||
});
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
const { response, blockchainName } = await this.runAddBlockWithRetry({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
resolveFreshState: () => this.resolveFreshBlockchainCursor(cleanLogin),
|
||||
buildPreimage: async ({ blockNumber, prevBlockHash }) => buildBlockPreimage({
|
||||
prevBlockHashHex: prevBlockHash,
|
||||
blockNumber,
|
||||
msgType,
|
||||
msgSubType,
|
||||
msgVersion,
|
||||
bodyBytes,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = response.payload || {};
|
||||
const acceptedNum = Number(payload?.serverLastGlobalNumber);
|
||||
@ -2249,79 +2542,22 @@ export class AuthService {
|
||||
if (!cleanLogin || !cleanParam) throw new Error('Не переданы login/param.');
|
||||
if (!cleanValue) throw new Error('Значение параметра не может быть пустым.');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
|
||||
|
||||
const user = await this.getUser(cleanLogin);
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
const freshCursor = {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
|
||||
};
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('На устройстве нет сохраненного приватного blockchainKey');
|
||||
}
|
||||
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
|
||||
|
||||
// Для USER_PARAM отправляем старт новой line-цепочки:
|
||||
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
|
||||
// Этот формат соответствует BodyHasLine правилам на сервере.
|
||||
const bodyBytes = makeUserParamBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
key: cleanParam,
|
||||
value: cleanValue,
|
||||
});
|
||||
|
||||
const preimage = concatBytes(
|
||||
int16Bytes(0),
|
||||
hexToBytes(prevBlockHash),
|
||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(4),
|
||||
int16Bytes(1),
|
||||
int16Bytes(1),
|
||||
bodyBytes,
|
||||
);
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
const response = await this.ws.request('AddBlock', {
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
let cursor = freshCursor;
|
||||
let response = await tryAdd(cursor);
|
||||
if (response.status !== 200) {
|
||||
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
|
||||
const knownHash = String(response?.payload?.serverLastGlobalHash || '');
|
||||
if (Number.isFinite(knownNum) && knownHash.length === 64) {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
return response.payload || {};
|
||||
const bodyBytes = makeUserParamBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
key: cleanParam,
|
||||
value: cleanValue,
|
||||
});
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: 4,
|
||||
msgSubType: 1,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
}
|
||||
|
||||
async addBlockConnection({ login, toLogin, subType, storagePwd }) {
|
||||
@ -2337,79 +2573,27 @@ export class AuthService {
|
||||
|
||||
const user = await this.getUser(cleanLogin);
|
||||
if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
|
||||
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
|
||||
const freshNum = Number(user?.serverLastGlobalNumber);
|
||||
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
const freshCursor = {
|
||||
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
|
||||
serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
|
||||
};
|
||||
|
||||
const targetUser = await this.getUser(cleanToLogin);
|
||||
if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
|
||||
const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
|
||||
|
||||
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||
const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
|
||||
if (!blockchainPrivatePkcs8) {
|
||||
throw new Error('На устройстве нет сохраненного приватного blockchainKey');
|
||||
}
|
||||
const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
|
||||
|
||||
const tryAdd = async (cursor) => {
|
||||
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
|
||||
const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
|
||||
|
||||
// Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку:
|
||||
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
|
||||
// target для user-связей указывает на HEADER пользователя (blockNumber=0).
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName,
|
||||
toBlockNumber: 0,
|
||||
toBlockHashHex: ZERO_HASH_HEX,
|
||||
});
|
||||
|
||||
const preimage = concatBytes(
|
||||
int16Bytes(0),
|
||||
hexToBytes(prevBlockHash),
|
||||
int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
|
||||
int32Bytes(blockNumber),
|
||||
int64Bytes(Math.floor(Date.now() / 1000)),
|
||||
int16Bytes(3),
|
||||
int16Bytes(cleanSubType),
|
||||
int16Bytes(1),
|
||||
bodyBytes,
|
||||
);
|
||||
|
||||
const hash32 = await sha256Bytes(preimage);
|
||||
const signatureBytes = await signBytes(privateKey, hash32);
|
||||
const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
|
||||
|
||||
return this.ws.request('AddBlock', {
|
||||
blockchainName,
|
||||
blockNumber,
|
||||
prevBlockHash,
|
||||
blockBytesB64: bytesToBase64(fullBlock),
|
||||
});
|
||||
};
|
||||
|
||||
let cursor = freshCursor;
|
||||
let response = await tryAdd(cursor);
|
||||
if (response.status !== 200) {
|
||||
const knownNum = Number(response?.payload?.serverLastGlobalNumber);
|
||||
const knownHash = String(response?.payload?.serverLastGlobalHash || '').trim().toLowerCase();
|
||||
if (Number.isFinite(knownNum) && knownHash.length === 64) {
|
||||
cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
|
||||
response = await tryAdd(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw opError('AddBlock', response);
|
||||
return response.payload || {};
|
||||
const bodyBytes = makeConnectionBodyBytes({
|
||||
lineCode: 0,
|
||||
prevLineNumber: -1,
|
||||
prevLineHashHex: ZERO_HASH_HEX,
|
||||
thisLineNumber: -1,
|
||||
toBlockchainName,
|
||||
toBlockNumber: 0,
|
||||
toBlockHashHex: ZERO_HASH_HEX,
|
||||
});
|
||||
return this.addBlockSigned({
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: 3,
|
||||
msgSubType: cleanSubType,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -188,6 +188,7 @@ function persistEntrySettings(settings) {
|
||||
shineServerHttp: String(settings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
|
||||
arweaveServer: String(settings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
||||
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(settings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
|
||||
remoteAddBlockSessionId: String(settings?.remoteAddBlockSessionId || ''),
|
||||
statuses: {
|
||||
solanaServer: String(settings?.statuses?.solanaServer || 'idle'),
|
||||
shineServerLogin: String(settings?.statuses?.shineServerLogin || settings?.statuses?.shineServer || 'idle'),
|
||||
@ -254,6 +255,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
shineServerHttp: String(storedEntrySettings?.shineServerHttp || DEFAULT_SHINE_SERVER_HTTP_VALUE),
|
||||
arweaveServer: String(storedEntrySettings?.arweaveServer || DEFAULT_ARWEAVE_SERVER),
|
||||
callPreflightTimeoutMs: Math.max(1000, Math.min(20000, Number(storedEntrySettings?.callPreflightTimeoutMs || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS) || DEFAULT_CALL_PREFLIGHT_TIMEOUT_MS)),
|
||||
remoteAddBlockSessionId: String(storedEntrySettings?.remoteAddBlockSessionId || ''),
|
||||
statuses: {
|
||||
solanaServer: String(storedEntrySettings?.statuses?.solanaServer || 'idle'),
|
||||
shineServerLogin: String(storedEntrySettings?.statuses?.shineServerLogin || storedEntrySettings?.statuses?.shineServer || 'idle'),
|
||||
@ -316,6 +318,11 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
export const state = createInitialState();
|
||||
|
||||
export const authService = new AuthService(state.entrySettings.shineServer);
|
||||
authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId);
|
||||
authService.setActiveSessionContext({
|
||||
login: state.session.login,
|
||||
sessionId: state.session.sessionId,
|
||||
});
|
||||
let onSessionReset = null;
|
||||
let onSessionAuthorized = null;
|
||||
|
||||
@ -738,6 +745,7 @@ export async function saveEntrySettings(nextSettings) {
|
||||
tools: normalizeToolsSettings(nextSettings.tools || state.entrySettings.tools),
|
||||
};
|
||||
persistEntrySettings(state.entrySettings);
|
||||
authService.setRemoteAddBlockSessionId(state.entrySettings.remoteAddBlockSessionId);
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
state.startHint = `Настройки входа сохранены. SHiNE: ${state.entrySettings.shineServerHttp}`;
|
||||
}
|
||||
@ -777,6 +785,7 @@ export function authorizeSession({
|
||||
login,
|
||||
sessionId,
|
||||
});
|
||||
authService.setActiveSessionContext({ login, sessionId });
|
||||
state.startHint = '';
|
||||
if (onSessionAuthorized) {
|
||||
onSessionAuthorized();
|
||||
@ -851,6 +860,7 @@ export async function terminateCurrentSession({ infoMessage = '', closeServerSes
|
||||
resetStateForSignedOut();
|
||||
await clearStoredMessages().catch(() => {});
|
||||
authService.close();
|
||||
authService.clearActiveSessionContext();
|
||||
if (infoMessage) {
|
||||
state.startHint = infoMessage;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user