Compare commits

...

5 Commits

21 changed files with 2578 additions and 228 deletions

View File

@ -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-слой и политика доступа.

View File

@ -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-лог |

View File

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

View File

@ -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%.
- По номеру билета показывается понятная сводка по очереди.
- Генерация ключей использует безопасный браузерный рандом и не требует сохранения секретного ключа.

View File

@ -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. Роли и ограничения

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,2 @@
client.version=1.2.282
server.version=1.2.262
client.version=1.2.286
server.version=1.2.266

View File

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

View File

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

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

View File

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

View File

@ -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('Выберите тип кошелька.');
}

View File

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

View File

@ -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,12 +1354,8 @@ 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 () => {
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);
@ -1236,39 +1367,161 @@ export class AuthService {
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 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,31 +2542,6 @@ 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,
@ -2282,46 +2550,14 @@ export class AuthService {
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),
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: 4,
msgSubType: 1,
msgVersion: 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 || {};
}
async addBlockConnection({ login, toLogin, subType, storagePwd }) {
@ -2337,32 +2573,10 @@ 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,
@ -2372,44 +2586,14 @@ export class AuthService {
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),
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: 3,
msgSubType: cleanSubType,
msgVersion: 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 || {};
}

View File

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