Обновлён server UI и приватные ключи переведены в base58

This commit is contained in:
AidarKC 2026-06-02 15:52:22 +04:00
parent 67f882b9bc
commit a06b76b800
14 changed files with 2277 additions and 5 deletions

View File

@ -11,6 +11,7 @@
## Структура проекта (кратко) ## Структура проекта (кратко)
- Серверный код SHiNE находится в папке `SHiNE-server/`. - Серверный код SHiNE находится в папке `SHiNE-server/`.
- Код клиентского UI SHiNE находится в папке `shine-UI/`. - Код клиентского UI SHiNE находится в папке `shine-UI/`.
- Веб-панель администратора сервера (управление Solana PDA сервера) — папка `shine-server-UI/`.
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения. - Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя. - Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.

View File

@ -7,3 +7,5 @@
## Справка по подпроектам ## Справка по подпроектам
- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`. - При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`.
- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`. - При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
- При работе внутри `shine-server-UI/` — читать `shine-server-UI/AGENTS.md`.
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.

View File

@ -0,0 +1,100 @@
# Синхронизация блоков и DM между серверами SHiNE
Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE.
## 1. Зачем нужна синхронизация
Пользователи SHiNE могут быть «приписаны» к разным серверам.
Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y):
1. Сервер X принимает сообщение;
2. Сервер X должен переслать DM-блок серверу Y;
3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B.
Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться,
чтобы любой партнёрский сервер мог отдать полную историю пользователя.
## 2. Список серверов синхронизации (`sync_servers`)
Каждый сервер регистрирует в своей Solana PDA список `sync_servers`
логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется.
- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера.
- Адрес каждого партнёрского сервера читается из его PDA на Solana.
- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`.
## 3. Что синхронизируется
### 3.1 Личные сообщения (DM)
- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt).
- Сервер-отправитель: при получении пары блоков от клиента перенаправляет их серверу получателя.
- Сервер-получатель: сохраняет блоки в `signed_messages_v2`, доставляет в активные сессии.
- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`.
### 3.2 Блоки пользовательского блокчейна
- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него.
- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`.
- Порядок блоков сохраняется (по глобальному номеру блока и хэшу).
- Дедупликация по глобальному номеру блока и хэшу.
## 4. Протокол синхронизации (целевой, не реализован)
### 4.1 Межсерверное соединение
- Серверы устанавливают постоянное WebSocket-соединение друг с другом.
- Адрес партнёра определяется по `server_address` из его Solana PDA.
- Аутентификация: подпись Ed25519 корневым ключом сервера (`root_key` из PDA).
- При разрыве — переподключение с экспоненциальным backoff.
### 4.2 Доставка новых данных (push)
- При получении нового блока или DM сервер немедленно пушит его всем подключённым партнёрам.
- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff.
### 4.3 Начальная синхронизация (backfill)
- При первом подключении к партнёру серверы обмениваются «курсорами» состояния:
последний глобальный номер блока, последний известный DM-ключ.
- Сервер с более полной историей досылает недостающее партнёру.
### 4.4 Разрешение конфликтов
- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока.
Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `Dev_Docs/Blockchain/README.md`).
- DM: конфликтов нет, `message_key` уникален.
## 5. Маршрутизация DM между серверами
При отправке DM от пользователя A к пользователю B:
1. Клиент A отправляет пару блоков на свой сервер X.
2. Сервер X определяет, на каком сервере зарегистрирован пользователь B.
- Сначала проверяет локально (если B зарегистрирован на X).
- Иначе читает PDA пользователя B из Solana и смотрит `access_servers`.
- Выбирает первый доступный сервер из `access_servers` и перенаправляет туда DM.
3. Сервер Y (из `access_servers` B) сохраняет и доставляет блоки.
Кэш адресов серверов: обновляется раз в сессию (при ошибке соединения).
## 6. Безопасность
- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое.
- Серверы не расшифровывают DM-контент (шифрование — задача следующего этапа).
- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере.
## 7. Статус реализации
| Компонент | Статус |
|-----------|--------|
| Регистрация серверной PDA в Solana | ✅ Реализовано |
| Чтение `sync_servers` из PDA | Нужна реализация |
| Межсерверный WebSocket-канал | Нужна реализация |
| Push новых DM партнёрам | Нужна реализация |
| Push блоков блокчейна партнёрам | Нужна реализация |
| Backfill при первом подключении | Нужна реализация |
| Маршрутизация DM через access_servers | Нужна реализация (заглушка) |
Текущая версия сервера работает без межсерверной синхронизации.
Синхронизация — задача следующего этапа разработки.

View File

@ -36,6 +36,7 @@
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах. - `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн. - `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений. - `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
- `medium/2026-06-02_сессионные_саб_серверы_в_pda.md` - несколько саб-серверов пользователя как типизированные сессии в PDA с версией записи.
### Дальнее будущее ### Дальнее будущее

View File

@ -0,0 +1,103 @@
# Сессионные саб-серверы в PDA пользователя
- Статус:
`future`
- Горизонт:
`medium`
- Ориентир:
после завершения первого этапа по пользовательским сессиям
- Основание:
Идея зафиксирована после обсуждения архитектуры пользовательских сессий и внутренних саб-серверов. Сейчас задача сознательно отложена: сначала нужно аккуратно ввести базовую модель сессий, а затем возвращаться к расширенной серверной роли.
## Зачем нужна фича
У одного пользователя может быть несколько доверенных внутренних саб-серверов, и каждый из них должен жить как отдельная пользовательская сессия, а не как отдельная особая сущность вне общей модели.
Это нужно, чтобы:
- хранить несколько саб-серверов у одного пользователя одновременно;
- различать обычные клиентские сессии и серверные сессии по явному типу;
- дать расширяемый формат записи с версией;
- использовать единый подход для DM, звонков и внутренних команд между сессиями.
## Целевая идея
В пользовательском PDA должен появиться список записей сессий, где каждая запись содержит как минимум:
- `sessionType` (`u8`);
- `sessionVersion` (`u8`);
- `sessionName`;
- `sessionPubKey`.
Предварительные значения:
- тип `1` - обычная пользовательская сессия;
- тип `10` - саб-сервер пользователя;
- версия `1` - первая рабочая версия формата записи сессии.
Важно: саб-серверов у одного пользователя может быть несколько.
## Архитектурный принцип
Внутренний протокол взаимодействия должен оставаться транспортным.
То есть SHiNE-сервер не должен разбирать прикладной смысл внутренней нагрузки саб-сервера, а должен:
- доставлять сообщения между сессиями;
- доставлять сигналы звонков между сессиями;
- хранить и маршрутизировать адресацию;
- не принимать на себя бизнес-логику содержимого внутренних команд.
## Что уже подтверждается текущим кодом
- Личные сообщения уже доставляются по всем сессиям целевого пользователя с отдельным учётом доставки на каждую сессию.
- Подтверждение доставки DM уже идёт отдельно по каждой сессии.
- Вызов звонка уже рассылается по нескольким активным сессиям пользователя.
- Сигналы звонка уже адресуются конкретной сессии, а stop-сигналы дублируются на остальные сессии того же пользователя.
Иными словами, текущая серверная логика ближе к модели "сервер доставляет между сессиями", чем к модели "сервер понимает внутренний протокол саб-сервера".
## Что нужно сделать при возврате к задаче
1. Согласовать финальный бинарный формат записи сессии в PDA пользователя.
2. Проверить, не меняет ли это уже опубликованный формат пользовательской PDA-записи.
3. Если формат PDA меняется, заранее предупредить пользователя и получить отдельное подтверждение.
4. Решить, где именно хранится массив сессий:
- в основной записи пользователя;
- в отдельной PDA-структуре расширения;
- или в смешанной схеме с базовой записью и внешними индексами.
5. Зафиксировать ограничения:
- максимальное число сессий;
- максимальную длину `sessionName`;
- правила удаления и обновления записи;
- правила ротации `sessionPubKey`.
6. Продумать, как UI и сервер будут отличать тип `1` и тип `10`.
7. Определить, какие внутренние сообщения саб-сервера останутся полностью прозрачными для SHiNE-сервера, а какие потребуют только технической маршрутизации.
8. Добавить API/операции чтения и обновления списка сессий, если для этого не хватит существующих механизмов.
9. После реализации обязательно обновить документацию.
## Что нужно обновить при реализации
- `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md`
- `Dev_Docs/Solana_Architecture/README.md`
- `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- `Dev_Docs/Keys/README.md`
- `Dev_Docs/Personal_Messages/README.md`, если изменится адресация DM по типам сессий
- `Dev_Docs/API/`, если появятся новые серверные операции или изменятся ответы
## Что пока не делать
- Не включать это автоматически в основной deploy сервера.
- Не менять сейчас Solana PDA-формат без отдельного подтверждения.
- Не добавлять временные поля в публичный API "на всякий случай".
## С какого места продолжать
Продолжать после завершения первой части:
1. описать минимальный формат записи пользовательской сессии;
2. отдельно решить, живут ли саб-серверы в том же списке, что и обычные сессии;
3. затем уже проектировать операции регистрации, обновления и отключения таких сессий.

69
SHiNE-server/AGENTS.md Normal file
View File

@ -0,0 +1,69 @@
# AGENTS.md — SHiNE-server
## Назначение
SHiNE-server — серверная часть мессенджера SHiNE: WebSocket-сервер, хранение блоков блокчейна
пользователей, доставка личных сообщений (DM), звонки.
## Структура папок
- `shine-server-net-server/` — точка входа, запуск HTTP/WS сервера
- `shine-server-net-protocol/` — обработчики операций (RPC и события WS)
- `shine-server-db/` — DAO, SQL-схема, SQLite
- `shine-server-blockchain/` — логика хранения и проверки блоков блокчейна
- `shine-server-crypto/` — криптографические утилиты
- `shine-server-config/` — конфигурация сервера
- `shine-server-log/` — логирование
- `shine-server-geo/` — геолокация IP
## Настройка сервера в Solana (Solana PDA)
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
В PDA хранятся:
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
с которыми синхронизируются блоки и DM;
- **корневой ключ** сервера (`root_key`).
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
**Управление серверной PDA выполняется через Web-панель администратора:**
```
shine-server-UI/index.html
```
Страницы:
- `create-server-pda.html` — первичная регистрация серверного аккаунта;
- `update-server-pda.html` — обновление адреса или списка sync_servers.
Для регистрации нужен полный keyBundle (root + device + blockchain).
Для обновления — только root + device (blockchain-ключ не нужен).
Актуальные адреса программ Solana (devnet):
- `shine_users`: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
- `shine_payments`: `m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR`
Подробнее: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
## Синхронизация с партнёрскими серверами
Сервер должен синхронизировать блоки блокчейна и DM с серверами-партнёрами из `sync_servers`.
Детали: `Dev_Docs/Blockchain/sync-between-servers.md`
## Деплой
```
./gradlew deployServer
```
Хост по умолчанию: `player@93.170.12.154` (shineup.me).
Логи на проде:
- `/home/player/SHiNE/shine-server/logs/app.log`
- `/home/player/SHiNE/shine-server/logs/call-delivery-events.log`
## Язык
Комментарии в коде, документация и commit-сообщения — на русском языке.

View File

@ -1,2 +1,2 @@
client.version=1.2.110 client.version=1.2.111
server.version=1.2.102 server.version=1.2.103

View File

@ -175,9 +175,10 @@ function serializeCreateUserPdaArgs(
b.vecU8(new Uint8Array(32)); // last_block_hash b.vecU8(new Uint8Array(32)); // last_block_hash
b.vecU8(lastBlockSig64); // last_block_signature b.vecU8(lastBlockSig64); // last_block_signature
b.str(''); // arweave_tx_id b.str(''); // arweave_tx_id
b.bool(false); // is_server b.bool(false); // is_server
b.bytes32(new Uint8Array(32)); // server_key (default) b.u8(0); // address_format_type
b.str(''); // server_address b.u8(0); // address_format_version
b.str(''); // server_address
b.vecStr([]); // sync_servers b.vecStr([]); // sync_servers
b.vecStr(['shineup.me']); // access_servers b.vecStr(['shineup.me']); // access_servers
b.u8(0); // trusted_count b.u8(0); // trusted_count

82
shine-server-UI/AGENTS.md Normal file
View File

@ -0,0 +1,82 @@
# AGENTS.md — shine-server-UI
## Назначение
`shine-server-UI/` — автономная веб-панель администратора для управления серверным аккаунтом SHiNE
в Solana (регистрация и обновление `user_pda` с флагом `is_server=true`).
Это не часть основного клиентского SPA (`shine-UI/`). Страницы — самостоятельные HTML-файлы,
открываемые напрямую в браузере. Никакого бэкенда нет.
## Структура файлов
```
shine-server-UI/
index.html — главная страница с навигацией
create-server-pda.html — регистрация нового серверного аккаунта
update-server-pda.html — обновление адреса/sync_servers существующей PDA
styles.css — тёмная тема
js/
server-pda-core.js — вся логика: парсинг PDA, Borsh, криптография, Solana
```
## Как пользоваться
### Регистрация сервера (`create-server-pda.html`)
Открыть страницу в браузере (требуется HTTPS для WebCrypto — локально либо через сервер).
Ввести:
- **Логин сервера** — уникальный логин в Solana (только a-z, 0-9, _ ; без точки ; макс. 20 символов).
- **Адрес сервера** — полный WebSocket/HTTP URL, например `https://shineup.me/ws`.
- **sync_servers** — логины SHiNE-аккаунтов серверов-партнёров (по одному на строку).
**Способ ввода ключей (переключатель):**
- **«Из пароля»** — ввести пароль. Ключи автоматически выводятся из логина + пароля
по той же схеме, что SHiNE-клиент (Argon2id + Ed25519). Занимает 25 сек.
На страницах сервера публичные и приватные ключи показываются в base58, приватный ключ
хранится как 32-байтовый seed в base58.
- **«JSON ключей»** — вставить keyBundle JSON с тремя парами (rootPair, devicePair, blockchainPair).
На **device-ключе** должно быть достаточно SOL для оплаты транзакции регистрации.
### Обновление настроек сервера (`update-server-pda.html`)
1. Ввести логин и нажать **«Загрузить PDA»** — страница прочитает существующую PDA из Solana и
покажет текущие данные.
2. Изменить адрес сервера или список sync_servers.
3. Выбрать способ ввода ключей:
- **«Из пароля»** — ввести пароль (логин берётся из поля выше);
- **«JSON ключей»** — вставить keyBundle (достаточно rootPair + devicePair).
Blockchain-ключ для обновления не нужен — существующая подпись из PDA переиспользуется.
При ручном вводе допустим base58 seed; если blockchain seed не указан, обновление
использует уже сохранённую подпись последнего блока.
4. Нажать **«Обновить PDA»**.
## Ключевой файл логики
`js/server-pda-core.js` — автономный ES-модуль (без зависимостей на shine-UI).
Экспортирует:
- `readServerPdaData({ login, solanaEndpoint })` — читает и парсит PDA из Solana;
- `registerServerOnSolana({ login, keyBundle, serverAddress, syncServers, ... })`;
- `updateServerOnSolana({ login, keyBundle, serverAddress, syncServers, ... })`;
- `parsePdaData(rawBytes)` — парсит бинарный формат PDA (matches Rust `deserialize_record_from_pda`).
## Связанные документы
- Формат PDA: `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md`
- Деплой Solana-программ: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- Синхронизация между серверами: `Dev_Docs/Blockchain/sync-between-servers.md`
- Настройки сервера: `SHiNE-server/AGENTS.md`
## Правила при доработке
- Формат Borsh-аргументов в `server-pda-core.js` должен строго соответствовать
`UserMutableFields` в `shine-solana/shine/programs/shine_users/src/users.rs`.
- Бинарный формат PDA в `buildUnsignedRecordBytesServer` должен совпадать с
`serialize_unsigned_record` в Rust.
- При любом изменении формата Solana-программы (`users.rs`) — обновлять `server-pda-core.js`
и документ формата PDA в том же коммите.
- Язык кода и комментариев: русский.

View File

@ -0,0 +1,468 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Регистрация сервера — SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.pwd-wrap { display: flex; }
.pwd-wrap input { flex: 1; border-radius: var(--radius) 0 0 var(--radius); }
.btn-eye { border: 1px solid var(--border); border-left: none; background: #0d0d0d;
color: var(--text-muted); border-radius: 0 var(--radius) var(--radius) 0;
padding: 0 16px; cursor: pointer; font-size: 13px; }
.btn-eye:hover { color: var(--accent); border-color: var(--accent); }
.gen-msg { font-size: 12px; margin-top: 8px; padding: 8px 12px; border-radius: var(--radius); display: none; }
.gen-msg.ok { display:block; background:#1a2e1a; border:1px solid #2a4a2a; color:#7dcc7d; }
.gen-msg.err { display:block; background:#2e1a1a; border:1px solid #5a2a2a; color:#f08080; }
.kp-title { font-size:11px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px; }
.kp-row { display:flex; gap:8px; align-items:flex-start; margin-bottom:6px; }
.kp-row:last-child { margin-bottom:0; }
.kp-lbl { font-size:11px; color:var(--text-muted); min-width:60px; padding-top:10px; }
.kp-inp { flex:1; font-size:11px; font-family:monospace; padding:8px 10px; }
.kp-block { margin-bottom:14px; padding-bottom:14px; border-bottom:1px solid var(--border); }
.kp-block:last-child { border-bottom:none; margin-bottom:0; padding-bottom:0; }
.sec-lbl { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; margin:16px 0 10px; }
.sol-box { margin-top:14px; background:#0d1a0d; border:1px solid #2a4a2a; border-radius:var(--radius); padding:10px 14px; display:none; }
.sol-box.show { display:block; }
.sol-ttl { font-size:12px; font-weight:600; color:#7dcc7d; }
.sol-adr { font-family:monospace; font-size:12px; word-break:break-all; margin-top:4px; }
.sol-ht { font-size:11px; color:var(--text-muted); margin-top:4px; }
</style>
</head>
<body>
<div class="container">
<div class="nav-links">
<a href="index.html">← Назад</a>
<a href="update-server-pda.html">Обновить PDA</a>
</div>
<h1>Регистрация серверного аккаунта</h1>
<p class="subtitle">Создаёт user_pda в Solana с флагом is_server=true</p>
<div class="card">
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
<div class="hint">devnet: https://api.devnet.solana.com · mainnet: https://api.mainnet-beta.solana.com</div>
</div>
</div>
<div class="card">
<h2>Данные сервера</h2>
<div class="field">
<label>Логин сервера</label>
<input type="text" id="login" placeholder="shineupme" maxlength="20" />
<div class="hint">Только a-z, 0-9, _ · без точки · макс. 20 символов</div>
</div>
<div class="field">
<label>Адрес сервера (URL)</label>
<input type="text" id="serverAddress" placeholder="https://shineup.me/ws" />
</div>
<div class="field">
<label>Серверы синхронизации (sync_servers)</label>
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
</div>
<div class="field">
<label>Серверы доступа (access_servers, опционально)</label>
<textarea id="accessServers" placeholder="Обычно пусто для серверного PDA"></textarea>
</div>
</div>
<div class="card">
<h2>Ключи сервера</h2>
<div class="field">
<label>Пароль</label>
<div class="pwd-wrap">
<input type="password" id="password" placeholder="Пароль аккаунта сервера" autocomplete="new-password" />
<button class="btn-eye" id="btnEye" type="button">Показать</button>
</div>
<div class="hint">Нажмите «Сгенерировать» — поля ниже заполнятся из логина + пароля (Argon2id).<br/>Или введите ключи вручную.</div>
</div>
<div class="btn-row">
<button class="btn-secondary" id="btnGen" type="button">Сгенерировать ключи</button>
</div>
<div class="gen-msg" id="genMsg"></div>
<div class="sec-lbl">Секрет (master secret, base58)</div>
<div class="field" style="margin-bottom:0">
<input type="text" id="masterSecret" placeholder="32-байтовый master secret в base58 (~44 символа)" />
</div>
<div class="sec-lbl">Ключевые пары (base58)</div>
<div class="kp-block">
<div class="kp-title">Root Key — подпись PDA-записи</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="rootPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Blockchain Key — подпись LastBlockState</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="bchPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="bchPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Device Key — оплата транзакции Solana</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="devPub" placeholder="base58, ~44 символа (= Solana-адрес)" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="devPriv" placeholder="seed base58, ~44 символа" /></div>
<div class="sol-box" id="solBox">
<div class="sol-ttl">Положите SOL на этот адрес перед регистрацией:</div>
<div class="sol-adr" id="solAdr"></div>
<div class="sol-ht">Это Solana-адрес device-ключа (public key в base58 = Solana-адрес). С него оплачивается создание PDA.</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn-primary" id="btnCreate">Зарегистрировать сервер</button>
</div>
<div class="status" id="status"></div>
</div>
<script type="module">
// ===== CDN-загрузчики (единственные внешние зависимости) =====
let _sol = null, _arg = null;
const loadSol = async () => { if (!_sol) _sol = await import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); return _sol; };
const loadArg = async () => { if (!_arg) _arg = await import('https://esm.sh/@noble/hashes@1.8.0/argon2.js'); return _arg; };
// ===== Константы =====
const USERS_PGM = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
const PAY_PGM = 'm48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR';
const GUARD_PGM = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo';
const ED25519_PGM = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_IX = 'Sysvar1nstructions1111111111111111111111111';
const CREATE_DISC = new Uint8Array([139,157,13,41,142,174,226,214]);
// ===== Crypto =====
const sha256 = async b => new Uint8Array(await crypto.subtle.digest('SHA-256', b));
async function signEd25519(pkcs8B64, msg) {
const pkcs8 = b64ToBytes(pkcs8B64);
const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name:'Ed25519' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign({ name:'Ed25519' }, key, msg));
}
const b64ToBytes = b64 => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const bytesToB64 = b => btoa(String.fromCharCode(...b));
const b64urlToStd = s => { const n = s.replace(/-/g,'+').replace(/_/g,'/'); return n+'='.repeat((4-n.length%4)%4); };
const extractSeed32 = pkcs8B64 => b64ToBytes(pkcs8B64).slice(16, 48);
async function anchorDisc(name) {
return (await sha256(new TextEncoder().encode(`global:${name}`))).slice(0,8);
}
// ===== Base58 =====
function base58Enc(bytes) {
const A = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let n = 0n; for (const b of bytes) n = (n<<8n)|BigInt(b);
let r = ''; while (n>0n) { r = A[Number(n%58n)]+r; n/=58n; }
for (const b of bytes) { if (b!==0) break; r='1'+r; }
return r;
}
function b58Dec(s) {
const A = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let n = 0n;
for (const c of s) {
const i = A.indexOf(c);
if (i < 0) throw new Error('Недопустимый символ base58: ' + c);
n = n * 58n + BigInt(i);
}
let hex = n.toString(16); if (hex.length % 2) hex = '0' + hex;
const res = []; for (let i = 0; i < hex.length; i += 2) res.push(parseInt(hex.slice(i,i+2),16));
let zeros = 0; for (const c of s) { if (c !== '1') break; zeros++; }
return new Uint8Array(zeros + res.length).fill(0).map((_, i) => i < zeros ? 0 : res[i - zeros]);
}
// Обеспечивает ровно 32 байта (дополняет нулями слева при необходимости)
function to32(bytes) {
if (bytes.length === 32) return bytes;
if (bytes.length > 32) throw new Error(`Ожидалось 32 байта, получено ${bytes.length}`);
const out = new Uint8Array(32); out.set(bytes, 32 - bytes.length); return out;
}
// Подпись с использованием 32-байтового seed в base58
async function signWithSeedB58(seedB58, msg) {
const seed32 = to32(b58Dec(seedB58));
const pkcs8 = pkcs8FromSeed(seed32);
const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name:'Ed25519' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign({ name:'Ed25519' }, key, msg));
}
// ===== Borsh =====
const p32le = (buf,v) => { const n=v>>>0; buf.push(n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF); };
const p64le = (buf,v) => { const b=typeof v==='bigint'?v:BigInt(v); p32le(buf,Number(b&0xFFFFFFFFn)>>>0); p32le(buf,Number((b>>32n)&0xFFFFFFFFn)>>>0); };
class BB {
constructor(){ this._b=[]; }
u8(v){this._b.push(v&0xFF);}
u32(v){p32le(this._b,v);}
u64(v){p64le(this._b,v);}
bool(v){this.u8(v?1:0);}
b32(b){for(const x of b)this._b.push(x);}
vu8(b){this.u32(b.length);for(const x of b)this._b.push(x);}
str(s){const e=new TextEncoder().encode(s);this.u32(e.length);for(const x of e)this._b.push(x);}
vstr(a){this.u32(a.length);for(const s of a)this.str(s);}
raw(b){for(const x of b)this._b.push(x);}
done(){return new Uint8Array(this._b);}
}
// ===== Бинарный формат PDA =====
function lbsBytes(login, bchName, blockNum, blockHash32, usedBytes) {
const enc = new TextEncoder();
const buf = [...enc.encode('SHiNE_LAST_BLOCK')];
const lb = enc.encode(login); buf.push(lb.length,...lb);
const bb = enc.encode(bchName); buf.push(bb.length,...bb);
p32le(buf,blockNum);
for (const x of blockHash32) buf.push(x);
p64le(buf,usedBytes);
return new Uint8Array(buf);
}
function buildServerRecord({ login,createdAtMs,updatedAtMs,recordNum,prevHash32,rootKey32,devKey32,bchKey32,bchName,paidLimit,usedBytes,blockNum,blockHash32,blockSig64,arweaveId,srvAddr,fmtType,fmtVer,syncSrvs,accSrvs,trusted }) {
const enc = new TextEncoder();
const lB = enc.encode(login), bB = enc.encode(bchName);
const buf = [0x53,0x48,0x69,0x4E,0x45,1,0,0,0];
p64le(buf,createdAtMs); p64le(buf,updatedAtMs); p32le(buf,recordNum);
for (const x of prevHash32) buf.push(x);
buf.push(lB.length,...lB);
buf.push(6); // 6 блоков (сервер)
buf.push(1,0,...rootKey32); // RootKeyBlock
buf.push(2,0,...devKey32); // DeviceKeyBlock
buf.push(3,0,1,1,bB.length,...bB,...bchKey32); // BlockchainRegistryBlock
p64le(buf,paidLimit); p64le(buf,usedBytes); p32le(buf,blockNum);
for (const x of blockHash32) buf.push(x);
for (const x of blockSig64) buf.push(x);
if (arweaveId) { buf.push(1); const a=enc.encode(arweaveId); buf.push(a.length,...a); } else buf.push(0);
// ServerProfileBlock
buf.push(30,0,1,fmtType&0xFF,fmtVer&0xFF);
const sB=enc.encode(srvAddr); buf.push(sB.length,...sB);
buf.push(syncSrvs.length); for (const s of syncSrvs){const x=enc.encode(s);buf.push(x.length,...x);}
// AccessServersBlock
buf.push(40,0,accSrvs.length); for (const s of accSrvs){const x=enc.encode(s);buf.push(x.length,...x);}
// TrustedStateBlock
buf.push(50,0,trusted&0xFF);
const recLen=buf.length+64; buf[7]=recLen&0xFF; buf[8]=(recLen>>8)&0xFF;
return new Uint8Array(buf);
}
function serializeCreateArgs({ login,rootKey32,createdAtMs,devKey32,bchKey32,bchName,usedBytes,blockNum,blockHash32,blockSig64,arweaveId,srvAddr,fmtType,fmtVer,syncSrvs,accSrvs,trusted,rootSig64 }) {
const b = new BB();
b.raw(CREATE_DISC); b.str(login); b.b32(rootKey32); b.u64(createdAtMs); b.u64(0n);
b.b32(devKey32); b.b32(bchKey32); b.str(bchName);
b.u64(usedBytes); b.u32(blockNum); b.vu8(blockHash32); b.vu8(blockSig64); b.str(arweaveId);
b.bool(true); b.u8(fmtType); b.u8(fmtVer); b.str(srvAddr);
b.vstr(syncSrvs); b.vstr(accSrvs); b.u8(trusted); b.vu8(rootSig64);
return b.done();
}
function buildEd25519Ix(sig64, pub32, msgHash32) {
const d = new Uint8Array(144); const v = new DataView(d.buffer);
d[0]=1; d[1]=0;
v.setUint16(2,16,true); v.setUint16(4,0xFFFF,true);
v.setUint16(6,80,true); v.setUint16(8,0xFFFF,true);
v.setUint16(10,112,true); v.setUint16(12,32,true); v.setUint16(14,0xFFFF,true);
d.set(sig64,16); d.set(pub32,80); d.set(msgHash32,112);
return d;
}
function readEcoLimit(data) {
return new DataView(data.buffer,data.byteOffset,data.byteLength).getBigUint64(17,true);
}
// ===== Деривация ключей из пароля =====
function pkcs8FromSeed(seed32) {
const p = new Uint8Array([0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x70,0x04,0x22,0x04,0x20]);
const out = new Uint8Array(48); out.set(p); out.set(seed32,16); return out;
}
async function deriveEd25519FromMaster(master32, suffix) {
const material = `${bytesToB64(master32)}|${suffix}`;
const seed32 = await sha256(new TextEncoder().encode(material)); // 32-байтовый seed
const pkcs8 = pkcs8FromSeed(seed32);
const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name:'Ed25519' }, true, ['sign']);
const jwk = await crypto.subtle.exportKey('jwk', key);
if (!jwk.x) throw new Error(`Нет публичного ключа для ${suffix}`);
const pubBytes = b64ToBytes(b64urlToStd(jwk.x)); // 32 байта raw Ed25519 public key
return { publicKeyB58: base58Enc(pubBytes), privateSeedB58: base58Enc(seed32) };
}
async function deriveKeyBundle(login, password) {
const { argon2idAsync } = await loadArg();
const enc = new TextEncoder();
const loginNorm = login.trim().toLowerCase();
const saltSrc = `shine-auth-v2|login=${loginNorm}|suffix=master.secret`;
const salt = (await sha256(enc.encode(saltSrc))).slice(0,16);
const pass = enc.encode(`${loginNorm}\n${password}`);
const raw = await argon2idAsync(pass, salt, { t:2, m:65536, p:1, dkLen:32 });
const master32 = new Uint8Array(raw);
const [rootPair, blockchainPair, devicePair] = await Promise.all([
deriveEd25519FromMaster(master32,'root.key'),
deriveEd25519FromMaster(master32,'bch.key'),
deriveEd25519FromMaster(master32,'dev.key'),
]);
return { masterSecretB58: base58Enc(master32), rootPair, blockchainPair, devicePair };
}
// ===== Регистрация сервера =====
async function registerServer({ login, rootPub, rootPriv, bchPub, bchPriv, devPub, devPriv, srvAddr, syncSrvs, accSrvs, endpoint }) {
const sol = await loadSol();
const conn = new sol.Connection(endpoint, 'confirmed');
const enc = new TextEncoder();
const loginNorm = login.trim().toLowerCase();
const bchName = `${loginNorm}-001`;
const zero32 = new Uint8Array(32);
const uPgm = new sol.PublicKey(USERS_PGM);
const [pda] = sol.PublicKey.findProgramAddressSync([enc.encode('login='),enc.encode(loginNorm)],uPgm);
const [eco] = sol.PublicKey.findProgramAddressSync([enc.encode('shine_users_economy_config')],uPgm);
const [infl] = sol.PublicKey.findProgramAddressSync([enc.encode('shine_payments_inflow_vault')],new sol.PublicKey(PAY_PGM));
const guard = new sol.PublicKey(GUARD_PGM);
const ed25519= new sol.PublicKey(ED25519_PGM);
const sysIx = new sol.PublicKey(SYSVAR_IX);
const rootKey32 = to32(b58Dec(rootPub)); // pub: base58 → 32 bytes
const bchKey32 = to32(b58Dec(bchPub));
const devKey32 = to32(b58Dec(devPub));
const devKp = sol.Keypair.fromSeed(to32(b58Dec(devPriv))); // priv: seed base58 → 32 bytes
const ecoAcc = await conn.getAccountInfo(eco);
if (!ecoAcc) throw new Error('Economy config не инициализирован — запустите init_users_economy_config');
const paidLimit = readEcoLimit(ecoAcc.data);
const createdAtMs = BigInt(Date.now());
const lbsMsg = lbsBytes(loginNorm, bchName, 0, zero32, 0n);
const lbsHash = await sha256(lbsMsg);
const bchSig = await signWithSeedB58(bchPriv, lbsHash);
const unsignedRec = buildServerRecord({
login:loginNorm, createdAtMs, updatedAtMs:createdAtMs, recordNum:0, prevHash32:zero32,
rootKey32, devKey32, bchKey32, bchName,
paidLimit, usedBytes:0n, blockNum:0, blockHash32:zero32, blockSig64:bchSig, arweaveId:'',
srvAddr, fmtType:1, fmtVer:0, syncSrvs, accSrvs, trusted:0,
});
const recHash = await sha256(unsignedRec);
const rootSig = await signWithSeedB58(rootPriv, recHash);
const ixData = serializeCreateArgs({
login:loginNorm, rootKey32, createdAtMs, devKey32, bchKey32, bchName,
usedBytes:0n, blockNum:0, blockHash32:zero32, blockSig64:bchSig, arweaveId:'',
srvAddr, fmtType:1, fmtVer:0, syncSrvs, accSrvs, trusted:0, rootSig64:rootSig,
});
const tx = new sol.Transaction().add(
new sol.TransactionInstruction({ programId:ed25519, keys:[], data:buildEd25519Ix(rootSig,rootKey32,recHash) }),
new sol.TransactionInstruction({ programId:ed25519, keys:[], data:buildEd25519Ix(bchSig,bchKey32,lbsHash) }),
new sol.TransactionInstruction({
programId: uPgm,
keys:[
{pubkey:devKp.publicKey,isSigner:true,isWritable:true},
{pubkey:pda,isSigner:false,isWritable:true},
{pubkey:sol.SystemProgram.programId,isSigner:false,isWritable:false},
{pubkey:infl,isSigner:false,isWritable:true},
{pubkey:sysIx,isSigner:false,isWritable:false},
{pubkey:eco,isSigner:false,isWritable:false},
{pubkey:guard,isSigner:false,isWritable:false},
],
data:ixData,
}),
);
const sig = await sol.sendAndConfirmTransaction(conn, tx, [devKp], { commitment:'confirmed' });
return { sig, pdaAddr: pda.toBase58(), bchName };
}
// ===== UI =====
const $ = id => document.getElementById(id);
const statusEl = $('status');
const genMsgEl = $('genMsg');
function setStatus(txt, cls) { statusEl.className=`status ${cls}`; statusEl.textContent=txt; }
function setGenMsg(txt, cls) { genMsgEl.className=`gen-msg ${cls}`; genMsgEl.textContent=txt; }
// Показать/скрыть пароль
$('btnEye').addEventListener('click', () => {
const inp = $('password');
const vis = inp.type === 'password';
inp.type = vis ? 'text' : 'password';
$('btnEye').textContent = vis ? 'Скрыть' : 'Показать';
});
// Solana-адрес = device public key в base58 (это одно и то же)
function refreshSolAddr() {
const b58 = $('devPub').value.trim();
if (!b58) { $('solBox').classList.remove('show'); return; }
try {
to32(b58Dec(b58)); // проверяем что это корректный 32-байтовый base58
$('solAdr').textContent = b58; // device pub key в base58 = Solana адрес
$('solBox').classList.add('show');
} catch { $('solBox').classList.remove('show'); }
}
$('devPub').addEventListener('input', refreshSolAddr);
// Генерация ключей
$('btnGen').addEventListener('click', async () => {
const login = $('login').value.trim().toLowerCase();
const pwd = $('password').value;
if (!login) { setGenMsg('Сначала введите логин сервера', 'err'); return; }
if (!pwd) { setGenMsg('Введите пароль', 'err'); return; }
$('btnGen').disabled = true;
genMsgEl.className = 'gen-msg';
try {
const r = await deriveKeyBundle(login, pwd);
$('masterSecret').value = r.masterSecretB58;
$('rootPub').value = r.rootPair.publicKeyB58;
$('rootPriv').value = r.rootPair.privateSeedB58;
$('bchPub').value = r.blockchainPair.publicKeyB58;
$('bchPriv').value = r.blockchainPair.privateSeedB58;
$('devPub').value = r.devicePair.publicKeyB58;
$('devPriv').value = r.devicePair.privateSeedB58;
refreshSolAddr();
setGenMsg('✓ Ключи сгенерированы успешно', 'ok');
} catch(e) {
setGenMsg('Ошибка: ' + (e.message || String(e)), 'err');
} finally {
$('btnGen').disabled = false;
}
});
// Регистрация
const parseLogins = raw => raw.split('\n').map(s=>s.trim().toLowerCase()).filter(Boolean);
$('btnCreate').addEventListener('click', async () => {
const btn = $('btnCreate');
btn.disabled = true;
setStatus('Подготовка...', 'info');
try {
const login = $('login').value.trim().toLowerCase();
if (!login) throw new Error('Введите логин сервера');
const srvAddr = $('serverAddress').value.trim();
if (!srvAddr) throw new Error('Введите адрес сервера');
if (!$('rootPriv').value.trim()) throw new Error('Root Key приватный не заполнен');
if (!$('bchPriv').value.trim()) throw new Error('Blockchain Key приватный не заполнен');
if (!$('devPriv').value.trim()) throw new Error('Device Key приватный не заполнен');
setStatus('Загрузка Solana SDK и отправка транзакции...', 'info');
const res = await registerServer({
login,
rootPub: $('rootPub').value.trim(), rootPriv: $('rootPriv').value.trim(),
bchPub: $('bchPub').value.trim(), bchPriv: $('bchPriv').value.trim(),
devPub: $('devPub').value.trim(), devPriv: $('devPriv').value.trim(),
srvAddr,
syncSrvs: parseLogins($('syncServers').value),
accSrvs: parseLogins($('accessServers').value),
endpoint: $('endpoint').value.trim(),
});
setStatus(`✓ Сервер зарегистрирован!\n\nЛогин: ${login}\nPDA: ${res.pdaAddr}\nBlockchain: ${res.bchName}\nТранзакция: ${res.sig}`, 'success');
} catch(e) {
setStatus('Ошибка: ' + (e.message || String(e)), 'error');
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="container">
<h1>SHiNE Server Admin</h1>
<p class="subtitle">Панель управления Solana PDA для серверного аккаунта SHiNE</p>
<div class="card">
<h2>Действия</h2>
<div style="margin-bottom: 12px;">
<a href="create-server-pda.html">
<button class="btn-primary" style="width:100%">
Зарегистрировать серверный аккаунт (создать PDA)
</button>
</a>
</div>
<div>
<a href="update-server-pda.html">
<button class="btn-secondary" style="width:100%">
Обновить настройки сервера (update PDA)
</button>
</a>
</div>
</div>
<div class="card">
<h2>Как это работает</h2>
<p style="color:var(--text-muted);font-size:13px;line-height:1.7;">
Каждый SHiNE-сервер регистрирует свой аккаунт в Solana в виде <strong>user_pda</strong>
с флагом <code>is_server=true</code>.<br/><br/>
В PDA хранятся:<br/>
&nbsp;• адрес сервера (например, <code>https://shineup.me/ws</code>);<br/>
&nbsp;• список серверов-партнёров для синхронизации блокчейна и DM;<br/>
&nbsp;• криптографический корневой ключ сервера.<br/><br/>
Клиенты читают PDA прямо из Solana при попытке дозвониться до пользователя или
установить WebSocket-соединение через сервер.
</p>
</div>
<div class="card">
<h2>Что потребуется</h2>
<p style="color:var(--text-muted);font-size:13px;line-height:1.7;">
<strong>Для создания:</strong> полный keyBundle сервера (rootPair + devicePair + blockchainPair),
логин сервера (без точки, не более 20 символов), URL-адрес сервера, Solana-эндпоинт,
достаточный баланс SOL на device-ключе для комиссии.<br/><br/>
<strong>Для обновления:</strong> только rootPair + devicePair (blockchain-ключ не нужен).
</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,710 @@
// Логика управления серверной PDA в Solana (shine_users)
// Автономный модуль для панели администратора сервера SHiNE
const SHINE_USERS_PROGRAM_ID = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
const SHINE_PAYMENTS_PROGRAM_ID = 'm48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR';
const SHINE_LOGIN_GUARD_PROGRAM_ID = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo';
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
// Discriminator create_user_pda (sha256("global:create_user_pda")[0..8])
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
let _solanaLib = null;
async function loadSolanaLib() {
if (!_solanaLib) _solanaLib = await import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
return _solanaLib;
}
let _argon2Lib = null;
async function loadArgon2() {
if (!_argon2Lib) _argon2Lib = await import('https://esm.sh/@noble/hashes@1.8.0/argon2.js');
return _argon2Lib;
}
// -------------------------------------------------------------------
// Crypto (WebCrypto, Ed25519)
// -------------------------------------------------------------------
async function sha256Bytes(bytes) {
const buf = await crypto.subtle.digest('SHA-256', bytes);
return new Uint8Array(buf);
}
async function signEd25519(pkcs8B64, messageBytes) {
const pkcs8 = Uint8Array.from(atob(pkcs8B64), c => c.charCodeAt(0));
const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
const sig = await crypto.subtle.sign({ name: 'Ed25519' }, key, messageBytes);
return new Uint8Array(sig);
}
function base64ToBytes(b64) {
return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
}
function extractSeed32FromPkcs8B64(pkcs8B64) {
// Ed25519 PKCS8 (48 байт): seed расположен начиная с байта 16
return base64ToBytes(pkcs8B64).slice(16, 48);
}
async function anchorDiscriminator(name) {
const hash = await sha256Bytes(new TextEncoder().encode(`global:${name}`));
return hash.slice(0, 8);
}
// -------------------------------------------------------------------
// Borsh-кодирование (Anchor-совместимое)
// -------------------------------------------------------------------
function pushU32LE(buf, v) {
const n = v >>> 0;
buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF);
}
function pushU64LE(buf, bigV) {
const b = typeof bigV === 'bigint' ? bigV : BigInt(bigV);
const lo = Number(b & 0xFFFFFFFFn) >>> 0;
const hi = Number((b >> 32n) & 0xFFFFFFFFn) >>> 0;
pushU32LE(buf, lo);
pushU32LE(buf, hi);
}
class BorshBuf {
constructor() { this._b = []; }
u8(v) { this._b.push(v & 0xFF); }
u32(v) { pushU32LE(this._b, v); }
u64(v) { pushU64LE(this._b, v); }
bool(v) { this.u8(v ? 1 : 0); }
bytes32(b) { for (const x of b) this._b.push(x); }
vecU8(b) { this.u32(b.length); for (const x of b) this._b.push(x); }
str(s) {
const enc = new TextEncoder().encode(s);
this.u32(enc.length);
for (const x of enc) this._b.push(x);
}
vecStr(arr) {
this.u32(arr.length);
for (const s of arr) this.str(s);
}
raw(bytes) { for (const x of bytes) this._b.push(x); }
result() { return new Uint8Array(this._b); }
}
// -------------------------------------------------------------------
// Построение бинарного формата PDA (matches Rust serialize_unsigned_record)
// -------------------------------------------------------------------
function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) {
const enc = new TextEncoder();
const buf = [];
for (const x of enc.encode('SHiNE_LAST_BLOCK')) buf.push(x);
const loginB = enc.encode(login);
buf.push(loginB.length); for (const x of loginB) buf.push(x);
const bchB = enc.encode(blockchainName);
buf.push(bchB.length); for (const x of bchB) buf.push(x);
pushU32LE(buf, lastBlockNumber);
for (const x of lastBlockHash32) buf.push(x);
pushU64LE(buf, usedBytes);
return new Uint8Array(buf);
}
function buildUnsignedRecordBytesServer({
login, createdAtMs, updatedAtMs, recordNumber, prevHash32,
rootKey32, deviceKey32, blockchainKey32, blockchainName,
paidLimitBytes, usedBytes, lastBlockNumber, lastBlockHash32, lastBlockSig64, arweaveTxId,
serverAddress, addressFormatType, addressFormatVersion, syncServers, accessServers, trustedCount,
}) {
const enc = new TextEncoder();
const loginB = enc.encode(login);
const bchB = enc.encode(blockchainName);
const buf = [];
// Заголовок: MAGIC(5) + FORMAT_MAJOR(1) + FORMAT_MINOR(1) + record_len_placeholder(2)
buf.push(0x53, 0x48, 0x69, 0x4E, 0x45, 1, 0, 0, 0); // байты 0..8
pushU64LE(buf, createdAtMs);
pushU64LE(buf, updatedAtMs);
pushU32LE(buf, recordNumber);
for (const x of prevHash32) buf.push(x);
buf.push(loginB.length);
for (const x of loginB) buf.push(x);
buf.push(6); // blocks_count = 6 (сервер)
// RootKeyBlock (type=1, ver=0)
buf.push(1, 0);
for (const x of rootKey32) buf.push(x);
// DeviceKeyBlock (type=2, ver=0)
buf.push(2, 0);
for (const x of deviceKey32) buf.push(x);
// BlockchainRegistryBlock (type=3, ver=0, count=1, blockchain_type=1)
buf.push(3, 0, 1, 1);
buf.push(bchB.length); for (const x of bchB) buf.push(x);
for (const x of blockchainKey32) buf.push(x);
pushU64LE(buf, paidLimitBytes);
pushU64LE(buf, usedBytes);
pushU32LE(buf, lastBlockNumber);
for (const x of lastBlockHash32) buf.push(x);
for (const x of lastBlockSig64) buf.push(x);
if (arweaveTxId) {
buf.push(1);
const aTxB = enc.encode(arweaveTxId);
buf.push(aTxB.length); for (const x of aTxB) buf.push(x);
} else {
buf.push(0);
}
// ServerProfileBlock (type=30, ver=0)
buf.push(30, 0);
buf.push(1); // is_server = 1
buf.push(addressFormatType & 0xFF);
buf.push(addressFormatVersion & 0xFF);
const srvB = enc.encode(serverAddress);
buf.push(srvB.length); for (const x of srvB) buf.push(x);
buf.push(syncServers.length);
for (const srv of syncServers) {
const sB = enc.encode(srv);
buf.push(sB.length); for (const x of sB) buf.push(x);
}
// AccessServersBlock (type=40, ver=0)
buf.push(40, 0, accessServers.length);
for (const srv of accessServers) {
const sB = enc.encode(srv);
buf.push(sB.length); for (const x of sB) buf.push(x);
}
// TrustedStateBlock (type=50, ver=0)
buf.push(50, 0, trustedCount & 0xFF);
// Записываем record_len: (длина буфера + 64 байта подписи)
const recLen = buf.length + 64;
buf[7] = recLen & 0xFF;
buf[8] = (recLen >> 8) & 0xFF;
return new Uint8Array(buf);
}
// -------------------------------------------------------------------
// Borsh-сериализация Anchor-инструкций
// -------------------------------------------------------------------
function serializeCreateServerPdaArgs({
login, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
blockchainName, usedBytes, lastBlockNumber, lastBlockHash32,
lastBlockSig64, arweaveTxId, serverAddress, addressFormatType,
addressFormatVersion, syncServers, accessServers, trustedCount, rootSig64,
}) {
const b = new BorshBuf();
b.raw(CREATE_USER_PDA_DISCRIMINATOR);
b.str(login);
b.bytes32(rootKey32);
b.u64(createdAtMs);
b.u64(0n); // additional_limit
// UserMutableFields:
b.bytes32(deviceKey32);
b.bytes32(blockchainKey32);
b.str(blockchainName);
b.u64(usedBytes);
b.u32(lastBlockNumber);
b.vecU8(lastBlockHash32);
b.vecU8(lastBlockSig64);
b.str(arweaveTxId);
b.bool(true); // is_server
b.u8(addressFormatType);
b.u8(addressFormatVersion);
b.str(serverAddress);
b.vecStr(syncServers);
b.vecStr(accessServers);
b.u8(trustedCount);
b.vecU8(rootSig64);
return b.result();
}
async function serializeUpdateServerPdaArgs({
login, rootKey32, createdAtMs, updatedAtMs, version, prevHash32,
deviceKey32, blockchainKey32, blockchainName, usedBytes,
lastBlockNumber, lastBlockHash32, lastBlockSig64, arweaveTxId,
serverAddress, addressFormatType, addressFormatVersion,
syncServers, accessServers, trustedCount, rootSig64,
}) {
const discriminator = await anchorDiscriminator('update_user_pda');
const b = new BorshBuf();
b.raw(discriminator);
b.str(login);
b.bytes32(rootKey32);
b.u64(createdAtMs);
b.u64(updatedAtMs);
b.u32(version);
b.vecU8(prevHash32);
b.u64(0n); // additional_limit
// UserMutableFields:
b.bytes32(deviceKey32);
b.bytes32(blockchainKey32);
b.str(blockchainName);
b.u64(usedBytes);
b.u32(lastBlockNumber);
b.vecU8(lastBlockHash32);
b.vecU8(lastBlockSig64);
b.str(arweaveTxId);
b.bool(true); // is_server
b.u8(addressFormatType);
b.u8(addressFormatVersion);
b.str(serverAddress);
b.vecStr(syncServers);
b.vecStr(accessServers);
b.u8(trustedCount);
b.vecU8(rootSig64);
return b.result();
}
// -------------------------------------------------------------------
// Построитель Ed25519-инструкции Solana
// -------------------------------------------------------------------
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
const sigOff = 16, pkOff = 80, msgOff = 112;
const data = new Uint8Array(msgOff + 32);
const v = new DataView(data.buffer);
data[0] = 1; data[1] = 0;
v.setUint16(2, sigOff, true); v.setUint16(4, 0xFFFF, true);
v.setUint16(6, pkOff, true); v.setUint16(8, 0xFFFF, true);
v.setUint16(10, msgOff, true); v.setUint16(12, 32, true); v.setUint16(14, 0xFFFF, true);
data.set(sig64, sigOff);
data.set(pubkey32, pkOff);
data.set(msgHash32, msgOff);
return data;
}
// -------------------------------------------------------------------
// Парсер бинарных данных PDA (matches Rust deserialize_record_from_pda)
// -------------------------------------------------------------------
export function parsePdaData(raw) {
const d = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
if (d.length < 9) throw new Error('PDA слишком короткая');
if (String.fromCharCode(d[0], d[1], d[2], d[3], d[4]) !== 'SHiNE') {
throw new Error('Неверный magic в PDA');
}
const view = new DataView(d.buffer, d.byteOffset);
const recordLen = view.getUint16(7, true);
if (recordLen < 9 + 64 || recordLen > d.length) throw new Error('Неверный record_len');
// Подписанная часть = байты [0 .. recordLen-64)
const unsignedBytes = d.slice(0, recordLen - 64);
let cur = 9;
const ru8 = () => d[cur++];
const ru32 = () => { const v = view.getUint32(cur, true); cur += 4; return v; };
const ru64 = () => { const v = view.getBigUint64(cur, true); cur += 8; return v; };
const rBytes = n => { const s = d.slice(cur, cur + n); cur += n; return s; };
const rStr = () => { const len = ru8(); return new TextDecoder().decode(rBytes(len)); };
const createdAtMs = ru64();
const updatedAtMs = ru64();
const recordNumber = ru32();
const prevRecordHash = rBytes(32);
const login = rStr();
const blocksCount = ru8();
let rootKey32 = null, deviceKey32 = null, blockchainData = null;
let isServer = false, serverData = null;
let accessServers = [], trustedCount = 0;
for (let i = 0; i < blocksCount; i++) {
const blockType = ru8();
ru8(); // block_version
if (blockType === 1) {
rootKey32 = rBytes(32);
} else if (blockType === 2) {
deviceKey32 = rBytes(32);
} else if (blockType === 3) {
const count = ru8();
const blockchainType = ru8();
const blockchainName = rStr();
const blockchainPublicKey = rBytes(32);
const paidLimitBytes = ru64();
const usedBytes = ru64();
const lastBlockNumber = ru32();
const lastBlockHash = rBytes(32);
const lastBlockSignature = rBytes(64);
const arweavePresent = ru8();
const arweaveTxId = arweavePresent === 1 ? rStr() : '';
blockchainData = {
blockchainType, blockchainName, blockchainPublicKey,
paidLimitBytes, usedBytes, lastBlockNumber,
lastBlockHash, lastBlockSignature, arweaveTxId,
};
} else if (blockType === 30) {
if (ru8() === 1) {
isServer = true;
const addressFormatType = ru8();
const addressFormatVersion = ru8();
const serverAddress = rStr();
const syncCount = ru8();
const syncServers = [];
for (let j = 0; j < syncCount; j++) syncServers.push(rStr());
serverData = { addressFormatType, addressFormatVersion, serverAddress, syncServers };
}
} else if (blockType === 40) {
const cnt = ru8();
for (let j = 0; j < cnt; j++) accessServers.push(rStr());
} else if (blockType === 50) {
trustedCount = ru8();
}
}
const signature = d.slice(cur, cur + 64);
return {
recordLen, unsignedBytes,
createdAtMs, updatedAtMs, recordNumber, prevRecordHash,
login, rootKey32, deviceKey32, blockchainData,
isServer, serverData, accessServers, trustedCount, signature,
};
}
// -------------------------------------------------------------------
// Вспомогательная: читает start_bonus_limit из economy config PDA
// -------------------------------------------------------------------
function readStartBonusLimit(data) {
// Borsh: version(u8=1) + reg_fee(u64) + lamports_per_step(u64) = 17 байт до start_bonus_limit
return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigUint64(17, true);
}
// -------------------------------------------------------------------
// Читает и парсит существующую PDA с блокчейна
// -------------------------------------------------------------------
export async function readServerPdaData({ login, solanaEndpoint }) {
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(solanaEndpoint), 'confirmed');
const loginNorm = String(login).trim().toLowerCase();
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync(
[enc.encode('login='), enc.encode(loginNorm)],
usersProgram,
);
const ai = await connection.getAccountInfo(userPda, 'confirmed');
if (!ai) throw new Error(`PDA не найдена для логина «${loginNorm}»`);
const parsed = parsePdaData(ai.data);
parsed.pdaAddress = userPda.toBase58();
return parsed;
}
// -------------------------------------------------------------------
// Регистрация нового серверного аккаунта в Solana
// -------------------------------------------------------------------
export async function registerServerOnSolana({
login, keyBundle, serverAddress,
addressFormatType = 1, addressFormatVersion = 0,
syncServers = [], accessServers = [],
solanaEndpoint,
}) {
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(solanaEndpoint), 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
const loginGuardProgram = new solana.PublicKey(SHINE_LOGIN_GUARD_PROGRAM_ID);
const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID);
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
const enc = new TextEncoder();
const loginNorm = String(login).trim().toLowerCase();
const blockchainName = `${loginNorm}-001`;
const zeroHash32 = new Uint8Array(32);
const [userPda] = solana.PublicKey.findProgramAddressSync(
[enc.encode('login='), enc.encode(loginNorm)], usersProgram);
const [economyConfigPda] = solana.PublicKey.findProgramAddressSync(
[enc.encode('shine_users_economy_config')], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync(
[enc.encode('shine_payments_inflow_vault')], paymentsProgram);
const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64);
const deviceKeypair = solana.Keypair.fromSeed(
extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64));
const ecoAccount = await connection.getAccountInfo(economyConfigPda);
if (!ecoAccount) throw new Error('Economy config не инициализирован');
const paidLimitBytes = readStartBonusLimit(ecoAccount.data); // additional_limit = 0
const createdAtMs = BigInt(Date.now());
// Подписываем LastBlockState ключом блокчейна (начальное состояние: всё нули)
const lbsBytes = buildLastBlockStateBytes(loginNorm, blockchainName, 0, zeroHash32, 0n);
const lbsHash = await sha256Bytes(lbsBytes);
const lastBlockSig64 = await signEd25519(keyBundle.blockchainPair.privatePkcs8B64, lbsHash);
// Строим и подписываем беззнаковую запись PDA корневым ключом
const unsignedRecord = buildUnsignedRecordBytesServer({
login: loginNorm, createdAtMs, updatedAtMs: createdAtMs,
recordNumber: 0, prevHash32: zeroHash32,
rootKey32, deviceKey32, blockchainKey32, blockchainName,
paidLimitBytes, usedBytes: 0n, lastBlockNumber: 0,
lastBlockHash32: zeroHash32, lastBlockSig64, arweaveTxId: '',
serverAddress, addressFormatType, addressFormatVersion,
syncServers, accessServers, trustedCount: 0,
});
const unsignedHash = await sha256Bytes(unsignedRecord);
const rootSig64 = await signEd25519(keyBundle.rootPair.privatePkcs8B64, unsignedHash);
const ixData = serializeCreateServerPdaArgs({
login: loginNorm, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
blockchainName, usedBytes: 0n, lastBlockNumber: 0,
lastBlockHash32: zeroHash32, lastBlockSig64, arweaveTxId: '',
serverAddress, addressFormatType, addressFormatVersion,
syncServers, accessServers, trustedCount: 0, rootSig64,
});
const tx = new solana.Transaction().add(
new solana.TransactionInstruction({
programId: ed25519Program, keys: [],
data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash),
}),
new solana.TransactionInstruction({
programId: ed25519Program, keys: [],
data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash),
}),
new solana.TransactionInstruction({
programId: usersProgram,
keys: [
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: userPda, isSigner: false, isWritable: true },
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: inflowVault, isSigner: false, isWritable: true },
{ pubkey: sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: economyConfigPda, isSigner: false, isWritable: false },
{ pubkey: loginGuardProgram, isSigner: false, isWritable: false },
],
data: ixData,
}),
);
const signature = await solana.sendAndConfirmTransaction(
connection, tx, [deviceKeypair], { commitment: 'confirmed' });
return { signature, pdaAddress: userPda.toBase58(), blockchainName };
}
// -------------------------------------------------------------------
// Обновление серверного профиля в существующей PDA
// Для обновления нужен только root-ключ (подпись записи) + device-ключ (оплата).
// Blockchain-ключ не нужен — переиспользуем существующую подпись LastBlockState из PDA.
// -------------------------------------------------------------------
export async function updateServerOnSolana({
login, keyBundle, serverAddress,
addressFormatType, addressFormatVersion,
syncServers,
solanaEndpoint,
}) {
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(solanaEndpoint), 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID);
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
const enc = new TextEncoder();
const loginNorm = String(login).trim().toLowerCase();
const [userPda] = solana.PublicKey.findProgramAddressSync(
[enc.encode('login='), enc.encode(loginNorm)], usersProgram);
const [economyConfigPda] = solana.PublicKey.findProgramAddressSync(
[enc.encode('shine_users_economy_config')], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync(
[enc.encode('shine_payments_inflow_vault')], paymentsProgram);
// Читаем существующую PDA
const ai = await connection.getAccountInfo(userPda, 'confirmed');
if (!ai) throw new Error(`PDA не найдена для логина «${loginNorm}»`);
const pda = parsePdaData(ai.data);
if (!pda.isServer) throw new Error('Эта PDA не является серверной (is_server = false)');
const bch = pda.blockchainData;
const deviceKeypair = solana.Keypair.fromSeed(
extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64));
// Формат адреса: берём из аргументов или из существующей PDA
const fmtType = addressFormatType ?? pda.serverData?.addressFormatType ?? 1;
const fmtVersion = addressFormatVersion ?? pda.serverData?.addressFormatVersion ?? 0;
// prev_hash = sha256(unsigned_bytes предыдущей записи)
const prevHash32 = await sha256Bytes(pda.unsignedBytes);
const updatedAtMs = BigInt(Date.now());
const newVersion = pda.recordNumber + 1;
// Строим новую беззнаковую запись
const unsignedRecord = buildUnsignedRecordBytesServer({
login: loginNorm,
createdAtMs: pda.createdAtMs, updatedAtMs,
recordNumber: newVersion, prevHash32,
rootKey32: pda.rootKey32, deviceKey32: pda.deviceKey32,
blockchainKey32: bch.blockchainPublicKey, blockchainName: bch.blockchainName,
paidLimitBytes: bch.paidLimitBytes, usedBytes: bch.usedBytes,
lastBlockNumber: bch.lastBlockNumber,
lastBlockHash32: bch.lastBlockHash, lastBlockSig64: bch.lastBlockSignature,
arweaveTxId: bch.arweaveTxId,
serverAddress, addressFormatType: fmtType, addressFormatVersion: fmtVersion,
syncServers, accessServers: pda.accessServers, trustedCount: pda.trustedCount,
});
const unsignedHash = await sha256Bytes(unsignedRecord);
const rootSig64 = await signEd25519(keyBundle.rootPair.privatePkcs8B64, unsignedHash);
// Хэш LastBlockState из существующей PDA (те же данные — та же подпись)
const lbsBytes = buildLastBlockStateBytes(
loginNorm, bch.blockchainName,
bch.lastBlockNumber, bch.lastBlockHash, bch.usedBytes);
const lbsHash = await sha256Bytes(lbsBytes);
const ixData = await serializeUpdateServerPdaArgs({
login: loginNorm, rootKey32: pda.rootKey32,
createdAtMs: pda.createdAtMs, updatedAtMs,
version: newVersion, prevHash32,
deviceKey32: pda.deviceKey32, blockchainKey32: bch.blockchainPublicKey,
blockchainName: bch.blockchainName,
usedBytes: bch.usedBytes, lastBlockNumber: bch.lastBlockNumber,
lastBlockHash32: bch.lastBlockHash, lastBlockSig64: bch.lastBlockSignature,
arweaveTxId: bch.arweaveTxId,
serverAddress, addressFormatType: fmtType, addressFormatVersion: fmtVersion,
syncServers, accessServers: pda.accessServers, trustedCount: pda.trustedCount,
rootSig64,
});
const tx = new solana.Transaction().add(
// Ed25519: подпись новой записи корневым ключом
new solana.TransactionInstruction({
programId: ed25519Program, keys: [],
data: buildEd25519IxData(rootSig64, pda.rootKey32, unsignedHash),
}),
// Ed25519: переиспользуем существующую подпись LastBlockState из PDA
new solana.TransactionInstruction({
programId: ed25519Program, keys: [],
data: buildEd25519IxData(bch.lastBlockSignature, bch.blockchainPublicKey, lbsHash),
}),
new solana.TransactionInstruction({
programId: usersProgram,
keys: [
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: userPda, isSigner: false, isWritable: true },
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: inflowVault, isSigner: false, isWritable: true },
{ pubkey: sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: economyConfigPda, isSigner: false, isWritable: false },
],
data: ixData,
}),
);
const signature = await solana.sendAndConfirmTransaction(
connection, tx, [deviceKeypair], { commitment: 'confirmed' });
return { signature, pdaAddress: userPda.toBase58() };
}
// -------------------------------------------------------------------
// Деривация keyBundle из логина + пароля
// Идентична логике SHiNE-клиента (crypto-utils.js):
// masterSecret = Argon2id(login+"\n"+password, salt=sha256("shine-auth-v2|login=...|suffix=master.secret"))
// rootPair = Ed25519(sha256(base64(master) + "|root.key"))
// blockchainPair = Ed25519(sha256(base64(master) + "|bch.key"))
// devicePair = Ed25519(sha256(base64(master) + "|dev.key"))
// -------------------------------------------------------------------
function _b64urlToStd(s) {
const n = s.replace(/-/g, '+').replace(/_/g, '/');
return n + '='.repeat((4 - n.length % 4) % 4);
}
function _ed25519Pkcs8FromSeed(seed32) {
const prefix = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
const out = new Uint8Array(prefix.length + 32);
out.set(prefix); out.set(seed32, prefix.length);
return out;
}
async function _deriveEd25519PairFromMasterSecret(masterSecret32, suffix) {
const enc = new TextEncoder();
const material = `${btoa(String.fromCharCode(...masterSecret32))}|${suffix}`;
const seed = await sha256Bytes(enc.encode(material));
const pkcs8 = _ed25519Pkcs8FromSeed(seed);
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
if (!jwk.x) throw new Error(`Не удалось получить публичный ключ (suffix=${suffix})`);
const pubBytes = base64ToBytes(_b64urlToStd(jwk.x));
return {
publicKeyB64: btoa(String.fromCharCode(...pubBytes)),
privatePkcs8B64: btoa(String.fromCharCode(...pkcs8)),
};
}
/**
* Выводит полный keyBundle из логина и пароля.
* Та же самая логика, что используется в SHiNE-клиенте при регистрации.
*
* @param {string} login логин сервера (нормализуется в нижний регистр)
* @param {string} password пароль
* @param {function} [onProgress] коллбэк(0..1) прогресса Argon2id
* @returns {{ rootPair, blockchainPair, devicePair }}
*/
export async function deriveKeyBundleFromPassword({ login, password, onProgress }) {
const { argon2idAsync } = await loadArgon2();
const enc = new TextEncoder();
const loginNorm = String(login || '').trim().toLowerCase();
const pwd = String(password ?? '');
// Salt для master secret = sha256("shine-auth-v2|login=...|suffix=master.secret")[0..16]
const saltSource = `shine-auth-v2|login=${loginNorm}|suffix=master.secret`;
const saltFull = await sha256Bytes(enc.encode(saltSource));
const salt = saltFull.slice(0, 16);
const passBytes = enc.encode(`${loginNorm}\n${pwd}`);
const masterRaw = await argon2idAsync(passBytes, salt, {
t: 2, m: 65536, p: 1, dkLen: 32,
onProgress,
});
const masterSecret32 = new Uint8Array(masterRaw);
const [rootPair, blockchainPair, devicePair] = await Promise.all([
_deriveEd25519PairFromMasterSecret(masterSecret32, 'root.key'),
_deriveEd25519PairFromMasterSecret(masterSecret32, 'bch.key'),
_deriveEd25519PairFromMasterSecret(masterSecret32, 'dev.key'),
]);
const masterSecretB64 = btoa(String.fromCharCode(...masterSecret32));
return { masterSecretB64, rootPair, blockchainPair, devicePair };
}
// -------------------------------------------------------------------
// Кодирование байт в base58 (для отображения Solana-адреса)
// -------------------------------------------------------------------
export function base58Encode(bytes) {
const ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let num = 0n;
for (const b of bytes) num = (num << 8n) | BigInt(b);
let result = '';
while (num > 0n) {
result = ALPHA[Number(num % 58n)] + result;
num /= 58n;
}
for (const b of bytes) {
if (b !== 0) break;
result = '1' + result;
}
return result;
}

193
shine-server-UI/styles.css Normal file
View File

@ -0,0 +1,193 @@
/* SHiNE Server Admin UI — тёмная тема */
:root {
--bg: #111;
--surface: #1a1a1a;
--border: #2a2a2a;
--text: #e0e0e0;
--text-muted: #888;
--accent: #4a9eff;
--accent-hover: #6ab4ff;
--success: #4caf50;
--error: #f44336;
--warning: #ff9800;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
font-size: 14px;
line-height: 1.5;
padding: 24px 16px;
}
.container {
max-width: 640px;
margin: 0 auto;
}
h1 {
font-size: 20px;
font-weight: 600;
color: var(--accent);
margin-bottom: 4px;
}
.subtitle {
color: var(--text-muted);
margin-bottom: 24px;
font-size: 13px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.card h2 {
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text);
}
.field {
margin-bottom: 14px;
}
label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
input[type="text"], input[type="password"], textarea {
width: 100%;
background: #0d0d0d;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: monospace;
font-size: 13px;
padding: 10px 12px;
outline: none;
transition: border-color 0.15s;
resize: vertical;
}
input[type="text"]:focus, input[type="password"]:focus, textarea:focus {
border-color: var(--accent);
}
input[type="text"][readonly] {
opacity: 0.6;
}
textarea {
min-height: 80px;
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
.btn-row {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
border-radius: var(--radius);
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, background 0.15s;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-secondary {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.status {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 13px;
margin-top: 16px;
word-break: break-all;
display: none;
}
.status.info { display: block; background: #1a2433; border: 1px solid #2a4a6a; color: #7bb8ff; }
.status.success { display: block; background: #1a2e1a; border: 1px solid #2a4a2a; color: #7dcc7d; }
.status.error { display: block; background: #2e1a1a; border: 1px solid #5a2a2a; color: #f08080; }
.pda-info {
display: none;
margin-top: 12px;
}
.pda-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.pda-row:last-child { border-bottom: none; }
.pda-key { color: var(--text-muted); min-width: 160px; }
.pda-value { color: var(--text); font-family: monospace; text-align: right; word-break: break-all; }
.nav-links {
margin-bottom: 20px;
}
.nav-links a {
color: var(--accent);
text-decoration: none;
margin-right: 16px;
font-size: 13px;
}
.nav-links a:hover { text-decoration: underline; }
.section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 20px 0;
}

View File

@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Обновление PDA сервера — SHiNE Server Admin</title>
<link rel="stylesheet" href="styles.css" />
<style>
.pwd-wrap { display: flex; }
.pwd-wrap input { flex: 1; border-radius: var(--radius) 0 0 var(--radius); }
.btn-eye { border: 1px solid var(--border); border-left: none; background: #0d0d0d;
color: var(--text-muted); border-radius: 0 var(--radius) var(--radius) 0;
padding: 0 16px; cursor: pointer; font-size: 13px; }
.btn-eye:hover { color: var(--accent); border-color: var(--accent); }
.gen-msg { font-size: 12px; margin-top: 8px; padding: 8px 12px; border-radius: var(--radius); display: none; }
.gen-msg.ok { display:block; background:#1a2e1a; border:1px solid #2a4a2a; color:#7dcc7d; }
.gen-msg.err { display:block; background:#2e1a1a; border:1px solid #5a2a2a; color:#f08080; }
.kp-title { font-size:11px; font-weight:700; color:var(--accent); text-transform:uppercase; letter-spacing:.06em; margin-bottom:8px; }
.kp-row { display:flex; gap:8px; align-items:flex-start; margin-bottom:6px; }
.kp-row:last-child { margin-bottom:0; }
.kp-lbl { font-size:11px; color:var(--text-muted); min-width:60px; padding-top:10px; }
.kp-inp { flex:1; font-size:11px; font-family:monospace; padding:8px 10px; }
.kp-block { margin-bottom:14px; padding-bottom:14px; border-bottom:1px solid var(--border); }
.kp-block:last-child { border-bottom:none; margin-bottom:0; padding-bottom:0; }
.sec-lbl { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; margin:16px 0 10px; }
.sol-box { margin-top:14px; background:#0d1a0d; border:1px solid #2a4a2a; border-radius:var(--radius); padding:10px 14px; display:none; }
.sol-box.show { display:block; }
.sol-ttl { font-size:12px; font-weight:600; color:#7dcc7d; }
.sol-adr { font-family:monospace; font-size:12px; word-break:break-all; margin-top:4px; }
.sol-ht { font-size:11px; color:var(--text-muted); margin-top:4px; }
.muted { font-size:12px; color:var(--text-muted); margin-bottom:14px; line-height:1.6; }
</style>
</head>
<body>
<div class="container">
<div class="nav-links">
<a href="index.html">← Назад</a>
<a href="create-server-pda.html">Создать PDA</a>
</div>
<h1>Обновление PDA сервера</h1>
<p class="subtitle">Меняет адрес сервера или список серверов синхронизации</p>
<div class="card">
<h2>Параметры Solana</h2>
<div class="field">
<label>Solana Endpoint</label>
<input type="text" id="endpoint" value="https://api.devnet.solana.com" />
</div>
</div>
<div class="card">
<h2>Загрузить существующую PDA</h2>
<div class="field">
<label>Логин сервера</label>
<input type="text" id="login" placeholder="shineupme" maxlength="20" />
</div>
<div class="btn-row">
<button class="btn-secondary" id="btnLoad">Загрузить PDA</button>
</div>
<div class="pda-info" id="pdaInfo">
<hr class="section-divider" />
<div class="pda-row"><span class="pda-key">PDA адрес</span><span class="pda-value" id="iAddr"></span></div>
<div class="pda-row"><span class="pda-key">Версия</span><span class="pda-value" id="iVer"></span></div>
<div class="pda-row"><span class="pda-key">Создан</span><span class="pda-value" id="iCreated"></span></div>
<div class="pda-row"><span class="pda-key">Обновлён</span><span class="pda-value" id="iUpdated"></span></div>
<div class="pda-row"><span class="pda-key">Адрес сервера</span><span class="pda-value" id="iSrvAddr"></span></div>
<div class="pda-row"><span class="pda-key">sync_servers</span><span class="pda-value" id="iSync"></span></div>
<div class="pda-row"><span class="pda-key">Blockchain</span><span class="pda-value" id="iBch"></span></div>
<div class="pda-row"><span class="pda-key">Paid limit</span><span class="pda-value" id="iLimit"></span></div>
</div>
</div>
<div id="updateForm" style="display:none">
<div class="card">
<h2>Новые параметры сервера</h2>
<div class="field">
<label>Новый адрес сервера (URL)</label>
<input type="text" id="serverAddress" placeholder="https://shineup.me/ws" />
</div>
<div class="field">
<label>Новые серверы синхронизации (sync_servers)</label>
<textarea id="syncServers" placeholder="По одному логину на строку (можно оставить пустым)"></textarea>
</div>
</div>
<div class="card">
<h2>Ключи для подписи и оплаты</h2>
<p class="muted">Root-ключ подписывает новую PDA-запись. Device-ключ оплачивает транзакцию.<br/>Blockchain-ключ не нужен — подпись LastBlockState из PDA переиспользуется автоматически.</p>
<div class="field">
<label>Пароль</label>
<div class="pwd-wrap">
<input type="password" id="password" placeholder="Пароль аккаунта сервера" autocomplete="current-password" />
<button class="btn-eye" id="btnEye" type="button">Показать</button>
</div>
<div class="hint">Нажмите «Сгенерировать» — поля ниже заполнятся из логина + пароля.<br/>Или введите ключи вручную.</div>
</div>
<div class="btn-row">
<button class="btn-secondary" id="btnGen" type="button">Сгенерировать ключи</button>
</div>
<div class="gen-msg" id="genMsg"></div>
<div class="sec-lbl">Секрет (master secret, base58)</div>
<div class="field" style="margin-bottom:0">
<input type="text" id="masterSecret" placeholder="32-байтовый master secret в base58 (~44 символа)" />
</div>
<div class="sec-lbl">Ключевые пары (base58)</div>
<div class="kp-block">
<div class="kp-title">Root Key — подпись PDA-записи</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="rootPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="rootPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Blockchain Key — справочно, при обновлении не используется</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="bchPub" placeholder="base58, ~44 символа" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="bchPriv" placeholder="seed base58, ~44 символа" /></div>
</div>
<div class="kp-block">
<div class="kp-title">Device Key — оплата транзакции Solana</div>
<div class="kp-row"><span class="kp-lbl">Публичный</span><input class="kp-inp" type="text" id="devPub" placeholder="base58, ~44 символа (= Solana-адрес)" /></div>
<div class="kp-row"><span class="kp-lbl">Приватный</span><input class="kp-inp" type="text" id="devPriv" placeholder="seed base58, ~44 символа" /></div>
<div class="sol-box" id="solBox">
<div class="sol-ttl">Положите SOL на этот адрес перед обновлением:</div>
<div class="sol-adr" id="solAdr"></div>
<div class="sol-ht">Это Solana-адрес (base58) device-ключа. С него оплачивается транзакция.</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn-primary" id="btnUpdate">Обновить PDA</button>
</div>
</div>
<div class="status" id="status"></div>
</div>
<script type="module">
// ===== CDN =====
let _sol=null, _arg=null;
const loadSol = async()=>{ if(!_sol)_sol=await import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); return _sol; };
const loadArg = async()=>{ if(!_arg)_arg=await import('https://esm.sh/@noble/hashes@1.8.0/argon2.js'); return _arg; };
// ===== Константы =====
const USERS_PGM='FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
const PAY_PGM='m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR';
const ED25519_PGM='Ed25519SigVerify111111111111111111111111111';
const SYSVAR_IX='Sysvar1nstructions1111111111111111111111111';
// ===== Crypto =====
const sha256 = async b => new Uint8Array(await crypto.subtle.digest('SHA-256',b));
const b64ToBytes = b64 => Uint8Array.from(atob(b64),c=>c.charCodeAt(0));
const bytesToB64 = b => btoa(String.fromCharCode(...b));
const b64urlToStd = s => { const n=s.replace(/-/g,'+').replace(/_/g,'/'); return n+'='.repeat((4-n.length%4)%4); };
async function anchorDisc(name) {
return (await sha256(new TextEncoder().encode(`global:${name}`))).slice(0,8);
}
// ===== Base58 =====
function b58Enc(bytes) {
const A='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let n=0n; for(const b of bytes)n=(n<<8n)|BigInt(b);
let r=''; while(n>0n){r=A[Number(n%58n)]+r;n/=58n;}
for(const b of bytes){if(b!==0)break;r='1'+r;}
return r;
}
function b58Dec(s) {
const A='123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let n=0n;
for (const c of s) {
const i = A.indexOf(c);
if (i < 0) throw new Error('Недопустимый символ base58: ' + c);
n = n * 58n + BigInt(i);
}
let hex = n.toString(16);
if (hex.length % 2) hex = '0' + hex;
const bytes = [];
for (let i = 0; i < hex.length; i += 2) bytes.push(parseInt(hex.slice(i, i + 2), 16));
let zeros = 0;
for (const c of s) {
if (c !== '1') break;
zeros++;
}
return Uint8Array.from([...new Array(zeros).fill(0), ...bytes]);
}
function to32(bytes) {
if (bytes.length === 32) return bytes;
if (bytes.length > 32) throw new Error(`Ожидалось 32 байта, получено ${bytes.length}`);
const out = new Uint8Array(32);
out.set(bytes, 32 - bytes.length);
return out;
}
function pkcs8FromSeed(seed32){
const p=new Uint8Array([0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x70,0x04,0x22,0x04,0x20]);
const out=new Uint8Array(48);out.set(p);out.set(seed32,16);return out;
}
async function signWithSeedB58(seedB58, msg) {
const seed32 = to32(b58Dec(seedB58));
const key = await crypto.subtle.importKey('pkcs8',pkcs8FromSeed(seed32),{name:'Ed25519'},false,['sign']);
return new Uint8Array(await crypto.subtle.sign({name:'Ed25519'},key,msg));
}
// ===== Borsh =====
const p32=(buf,v)=>{const n=v>>>0;buf.push(n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF);};
const p64=(buf,v)=>{const b=typeof v==='bigint'?v:BigInt(v);p32(buf,Number(b&0xFFFFFFFFn)>>>0);p32(buf,Number((b>>32n)&0xFFFFFFFFn)>>>0);};
class BB{
constructor(){this._b=[];}
u8(v){this._b.push(v&0xFF);}u32(v){p32(this._b,v);}u64(v){p64(this._b,v);}
bool(v){this.u8(v?1:0);}b32(b){for(const x of b)this._b.push(x);}
vu8(b){this.u32(b.length);for(const x of b)this._b.push(x);}
str(s){const e=new TextEncoder().encode(s);this.u32(e.length);for(const x of e)this._b.push(x);}
vstr(a){this.u32(a.length);for(const s of a)this.str(s);}
raw(b){for(const x of b)this._b.push(x);}
done(){return new Uint8Array(this._b);}
}
// ===== PDA builder =====
function lbsBytes(login,bchName,blockNum,blockHash,usedBytes){
const enc=new TextEncoder(),buf=[...enc.encode('SHiNE_LAST_BLOCK')];
const l=enc.encode(login);buf.push(l.length,...l);
const b=enc.encode(bchName);buf.push(b.length,...b);
p32(buf,blockNum);for(const x of blockHash)buf.push(x);p64(buf,usedBytes);
return new Uint8Array(buf);
}
function buildServerRecord({login,createdAtMs,updatedAtMs,recordNum,prevHash32,rootKey32,devKey32,bchKey32,bchName,paidLimit,usedBytes,blockNum,blockHash32,blockSig64,arweaveId,srvAddr,fmtType,fmtVer,syncSrvs,accSrvs,trusted}){
const enc=new TextEncoder(),lB=enc.encode(login),bB=enc.encode(bchName);
const buf=[0x53,0x48,0x69,0x4E,0x45,1,0,0,0];
p64(buf,createdAtMs);p64(buf,updatedAtMs);p32(buf,recordNum);
for(const x of prevHash32)buf.push(x);
buf.push(lB.length,...lB);buf.push(6);
buf.push(1,0,...rootKey32);buf.push(2,0,...devKey32);
buf.push(3,0,1,1,bB.length,...bB,...bchKey32);
p64(buf,paidLimit);p64(buf,usedBytes);p32(buf,blockNum);
for(const x of blockHash32)buf.push(x);for(const x of blockSig64)buf.push(x);
if(arweaveId){buf.push(1);const a=enc.encode(arweaveId);buf.push(a.length,...a);}else buf.push(0);
buf.push(30,0,1,fmtType&0xFF,fmtVer&0xFF);
const sB=enc.encode(srvAddr);buf.push(sB.length,...sB);
buf.push(syncSrvs.length);for(const s of syncSrvs){const x=enc.encode(s);buf.push(x.length,...x);}
buf.push(40,0,accSrvs.length);for(const s of accSrvs){const x=enc.encode(s);buf.push(x.length,...x);}
buf.push(50,0,trusted&0xFF);
const rl=buf.length+64;buf[7]=rl&0xFF;buf[8]=(rl>>8)&0xFF;
return new Uint8Array(buf);
}
function buildEd25519Ix(sig64,pub32,msgHash){
const d=new Uint8Array(144),v=new DataView(d.buffer);
d[0]=1;d[1]=0;v.setUint16(2,16,true);v.setUint16(4,0xFFFF,true);
v.setUint16(6,80,true);v.setUint16(8,0xFFFF,true);
v.setUint16(10,112,true);v.setUint16(12,32,true);v.setUint16(14,0xFFFF,true);
d.set(sig64,16);d.set(pub32,80);d.set(msgHash,112);return d;
}
// ===== PDA parser =====
function parsePda(raw){
const d=raw instanceof Uint8Array?raw:new Uint8Array(raw);
if(d.length<9||String.fromCharCode(d[0],d[1],d[2],d[3],d[4])!=='SHiNE')throw new Error('Неверный magic в PDA');
const view=new DataView(d.buffer,d.byteOffset);
const recLen=view.getUint16(7,true);
if(recLen<73||recLen>d.length)throw new Error('Неверный record_len');
const unsignedBytes=d.slice(0,recLen-64);
let cur=9;
const ru8=()=>d[cur++];
const ru32=()=>{const v=view.getUint32(cur,true);cur+=4;return v;};
const ru64=()=>{const v=view.getBigUint64(cur,true);cur+=8;return v;};
const rB=n=>{const s=d.slice(cur,cur+n);cur+=n;return s;};
const rStr=()=>{const l=ru8();return new TextDecoder().decode(rB(l));};
const createdAtMs=ru64(),updatedAtMs=ru64(),recordNumber=ru32();
rB(32); // prevHash
const login=rStr(),blocksCount=ru8();
let rootKey32=null,devKey32=null,bchData=null,srvData=null,accSrvs=[],trusted=0,isServer=false;
for(let i=0;i<blocksCount;i++){
const bt=ru8();ru8();
if(bt===1)rootKey32=rB(32);
else if(bt===2)devKey32=rB(32);
else if(bt===3){
ru8();const bt2=ru8(),bchName=rStr(),bchPub=rB(32);
const paidLimit=ru64(),usedBytes=ru64(),blockNum=ru32();
const blockHash=rB(32),blockSig=rB(64),ap=ru8();
const arweaveId=ap===1?rStr():'';
bchData={bchName,bchPub,paidLimit,usedBytes,blockNum,blockHash,blockSig,arweaveId};
}else if(bt===30){
if(ru8()===1){isServer=true;const ft=ru8(),fv=ru8(),sa=rStr(),sc=ru8();
const ss=[];for(let j=0;j<sc;j++)ss.push(rStr());
srvData={fmtType:ft,fmtVer:fv,srvAddr:sa,syncSrvs:ss};}
}else if(bt===40){const c=ru8();for(let j=0;j<c;j++)accSrvs.push(rStr());}
else if(bt===50)trusted=ru8();
}
const sig=d.slice(cur,cur+64);
return{recLen,unsignedBytes,createdAtMs,updatedAtMs,recordNumber,login,rootKey32,devKey32,bchData,isServer,srvData,accSrvs,trusted,sig};
}
// ===== Деривация ключей =====
function pkcs8FromSeed(seed32){
const p=new Uint8Array([0x30,0x2e,0x02,0x01,0x00,0x30,0x05,0x06,0x03,0x2b,0x65,0x70,0x04,0x22,0x04,0x20]);
const out=new Uint8Array(48);out.set(p);out.set(seed32,16);return out;
}
async function deriveEd25519(master32,suffix){
const material=`${bytesToB64(master32)}|${suffix}`;
const seed=await sha256(new TextEncoder().encode(material));
const pkcs8=pkcs8FromSeed(seed);
const key=await crypto.subtle.importKey('pkcs8',pkcs8,{name:'Ed25519'},true,['sign']);
const jwk=await crypto.subtle.exportKey('jwk',key);
if(!jwk.x)throw new Error(`Нет pub для ${suffix}`);
return{publicKeyB58:b58Enc(b64ToBytes(b64urlToStd(jwk.x))),privateSeedB58:b58Enc(seed)};
}
async function deriveKeyBundle(login,pwd){
const{argon2idAsync}=await loadArg();
const enc=new TextEncoder(),loginNorm=login.trim().toLowerCase();
const saltSrc=`shine-auth-v2|login=${loginNorm}|suffix=master.secret`;
const salt=(await sha256(enc.encode(saltSrc))).slice(0,16);
const raw=await argon2idAsync(enc.encode(`${loginNorm}\n${pwd}`),salt,{t:2,m:65536,p:1,dkLen:32});
const m32=new Uint8Array(raw);
const[rootPair,blockchainPair,devicePair]=await Promise.all([
deriveEd25519(m32,'root.key'),deriveEd25519(m32,'bch.key'),deriveEd25519(m32,'dev.key'),
]);
return{masterSecretB58:b58Enc(m32),rootPair,blockchainPair,devicePair};
}
// ===== Обновление PDA =====
async function updateServer({login,rootPriv,bchPriv,devPriv,srvAddr,syncSrvs,fmtType,fmtVer,endpoint}){
const sol=await loadSol();
const conn=new sol.Connection(endpoint,'confirmed'),enc=new TextEncoder();
const loginNorm=login.trim().toLowerCase();
const uPgm=new sol.PublicKey(USERS_PGM);
const[pda]=sol.PublicKey.findProgramAddressSync([enc.encode('login='),enc.encode(loginNorm)],uPgm);
const[eco]=sol.PublicKey.findProgramAddressSync([enc.encode('shine_users_economy_config')],uPgm);
const[infl]=sol.PublicKey.findProgramAddressSync([enc.encode('shine_payments_inflow_vault')],new sol.PublicKey(PAY_PGM));
const ed25519=new sol.PublicKey(ED25519_PGM),sysIx=new sol.PublicKey(SYSVAR_IX);
const ai=await conn.getAccountInfo(pda,'confirmed');
if(!ai)throw new Error(`PDA не найдена для логина «${loginNorm}»`);
const p=parsePda(ai.data);
if(!p.isServer)throw new Error('Эта PDA не является серверной');
const bch=p.bchData;
const devSeed=to32(b58Dec(devPriv)),devKp=sol.Keypair.fromSeed(devSeed);
const prevHash=await sha256(p.unsignedBytes);
const updatedAtMs=BigInt(Date.now()),newVer=p.recordNumber+1;
const lbs=lbsBytes(loginNorm,bch.bchName,bch.blockNum,bch.blockHash,bch.usedBytes);
const lbsHash=await sha256(lbs);
const bchSig=String(bchPriv || '').trim() ? await signWithSeedB58(bchPriv.trim(), lbsHash) : bch.blockSig;
const unsignedRec=buildServerRecord({
login:loginNorm,createdAtMs:p.createdAtMs,updatedAtMs,recordNum:newVer,prevHash32:prevHash,
rootKey32:p.rootKey32,devKey32:p.devKey32,bchKey32:bch.bchPub,bchName:bch.bchName,
paidLimit:bch.paidLimit,usedBytes:bch.usedBytes,blockNum:bch.blockNum,
blockHash32:bch.blockHash,blockSig64:bchSig,arweaveId:bch.arweaveId,
srvAddr,fmtType,fmtVer,syncSrvs,accSrvs:p.accSrvs,trusted:p.trusted,
});
const recHash=await sha256(unsignedRec);
const rootSig=await signWithSeedB58(rootPriv,recHash);
const disc=await anchorDisc('update_user_pda');
const b=new BB();
b.raw(disc);b.str(loginNorm);b.b32(p.rootKey32);b.u64(p.createdAtMs);b.u64(updatedAtMs);
b.u32(newVer);b.vu8(prevHash);b.u64(0n);
b.b32(p.devKey32);b.b32(bch.bchPub);b.str(bch.bchName);
b.u64(bch.usedBytes);b.u32(bch.blockNum);b.vu8(bch.blockHash);b.vu8(bchSig);b.str(bch.arweaveId);
b.bool(true);b.u8(fmtType);b.u8(fmtVer);b.str(srvAddr);
b.vstr(syncSrvs);b.vstr(p.accSrvs);b.u8(p.trusted);b.vu8(rootSig);
const ixData=b.done();
const tx=new sol.Transaction().add(
new sol.TransactionInstruction({programId:ed25519,keys:[],data:buildEd25519Ix(rootSig,p.rootKey32,recHash)}),
new sol.TransactionInstruction({programId:ed25519,keys:[],data:buildEd25519Ix(bchSig,bch.bchPub,lbsHash)}),
new sol.TransactionInstruction({
programId:uPgm,
keys:[
{pubkey:devKp.publicKey,isSigner:true,isWritable:true},
{pubkey:pda,isSigner:false,isWritable:true},
{pubkey:sol.SystemProgram.programId,isSigner:false,isWritable:false},
{pubkey:infl,isSigner:false,isWritable:true},
{pubkey:sysIx,isSigner:false,isWritable:false},
{pubkey:eco,isSigner:false,isWritable:false},
],
data:ixData,
}),
);
const sig=await sol.sendAndConfirmTransaction(conn,tx,[devKp],{commitment:'confirmed'});
return{sig,pdaAddr:pda.toBase58()};
}
// ===== UI =====
const $=id=>document.getElementById(id);
const statusEl=$('status'),genMsgEl=$('genMsg');
let _pda=null;
function setStatus(txt,cls){statusEl.className=`status ${cls}`;statusEl.textContent=txt;}
function setGenMsg(txt,cls){genMsgEl.className=`gen-msg ${cls}`;genMsgEl.textContent=txt;}
$('btnEye').addEventListener('click',()=>{
const i=$('password'),v=i.type==='password';
i.type=v?'text':'password';$('btnEye').textContent=v?'Скрыть':'Показать';
});
function refreshSolAddr(){
const b58=$('devPub').value.trim();
if(!b58){$('solBox').classList.remove('show');return;}
try{to32(b58Dec(b58));$('solAdr').textContent=b58;$('solBox').classList.add('show');}
catch{$('solBox').classList.remove('show');}
}
$('devPub').addEventListener('input',refreshSolAddr);
$('btnLoad').addEventListener('click',async()=>{
const btn=$('btnLoad');btn.disabled=true;
setStatus('Загрузка PDA из Solana...','info');
_pda=null;$('pdaInfo').style.display='none';$('updateForm').style.display='none';
try{
const login=$('login').value.trim().toLowerCase();
if(!login)throw new Error('Введите логин сервера');
const sol=await loadSol();
const conn=new sol.Connection($('endpoint').value.trim(),'confirmed');
const enc=new TextEncoder();
const uPgm=new sol.PublicKey(USERS_PGM);
const[pda]=sol.PublicKey.findProgramAddressSync([enc.encode('login='),enc.encode(login)],uPgm);
const ai=await conn.getAccountInfo(pda,'confirmed');
if(!ai)throw new Error(`PDA не найдена для «${login}»`);
const p=parsePda(ai.data);
if(!p.isServer)throw new Error('Эта PDA не является серверной');
_pda={...p,pdaAddr:pda.toBase58()};
const fmtTs=ms=>ms?new Date(Number(ms)).toLocaleString('ru'):'—';
const fmtB=n=>{const v=Number(n||0);return v<1024?`${v} B`:v<1048576?`${(v/1024).toFixed(1)} KB`:`${(v/1048576).toFixed(2)} MB`;};
$('iAddr').textContent=_pda.pdaAddr;$('iVer').textContent=`#${p.recordNumber}`;
$('iCreated').textContent=fmtTs(p.createdAtMs);$('iUpdated').textContent=fmtTs(p.updatedAtMs);
$('iSrvAddr').textContent=p.srvData?.srvAddr||'—';
$('iSync').textContent=p.srvData?.syncSrvs?.length?p.srvData.syncSrvs.join(', '):'(пусто)';
$('iBch').textContent=p.bchData?.bchName||'—';$('iLimit').textContent=fmtB(p.bchData?.paidLimit);
$('pdaInfo').style.display='block';
$('serverAddress').value=p.srvData?.srvAddr||'';
$('syncServers').value=(p.srvData?.syncSrvs||[]).join('\n');
$('updateForm').style.display='block';
statusEl.className='status';statusEl.textContent='';
}catch(e){setStatus('Ошибка загрузки: '+(e.message||String(e)),'error');}
finally{btn.disabled=false;}
});
$('btnGen').addEventListener('click',async()=>{
const login=$('login').value.trim().toLowerCase(),pwd=$('password').value;
if(!login){setGenMsg('Логин не заполнен','err');return;}
if(!pwd){setGenMsg('Введите пароль','err');return;}
$('btnGen').disabled=true;genMsgEl.className='gen-msg';
try{
const r=await deriveKeyBundle(login,pwd);
$('masterSecret').value=r.masterSecretB58;
$('rootPub').value=r.rootPair.publicKeyB58;$('rootPriv').value=r.rootPair.privateSeedB58;
$('bchPub').value=r.blockchainPair.publicKeyB58;$('bchPriv').value=r.blockchainPair.privateSeedB58;
$('devPub').value=r.devicePair.publicKeyB58;$('devPriv').value=r.devicePair.privateSeedB58;
refreshSolAddr();setGenMsg('✓ Ключи сгенерированы успешно','ok');
}catch(e){setGenMsg('Ошибка: '+(e.message||String(e)),'err');}
finally{$('btnGen').disabled=false;}
});
const parseLogins=raw=>raw.split('\n').map(s=>s.trim().toLowerCase()).filter(Boolean);
$('btnUpdate').addEventListener('click',async()=>{
if(!_pda){setStatus('Сначала загрузите PDA','error');return;}
const btn=$('btnUpdate');btn.disabled=true;
setStatus('Подготовка...','info');
try{
const login=$('login').value.trim().toLowerCase();
const srvAddr=$('serverAddress').value.trim();
if(!srvAddr)throw new Error('Введите новый адрес сервера');
if(!$('rootPriv').value.trim())throw new Error('Root Key приватный не заполнен');
if(!$('devPriv').value.trim())throw new Error('Device Key приватный не заполнен');
setStatus('Отправка транзакции в Solana...','info');
const res=await updateServer({
login,rootPriv:$('rootPriv').value.trim(),bchPriv:$('bchPriv').value.trim(),devPriv:$('devPriv').value.trim(),
srvAddr,syncSrvs:parseLogins($('syncServers').value),
fmtType:_pda.srvData?.fmtType??1,fmtVer:_pda.srvData?.fmtVer??0,
endpoint:$('endpoint').value.trim(),
});
setStatus(`✓ PDA обновлена!\n\nЛогин: ${login}\nPDA: ${res.pdaAddr}\nТранзакция: ${res.sig}`,'success');
_pda=null;
}catch(e){setStatus('Ошибка: '+(e.message||String(e)),'error');}
finally{btn.disabled=false;}
});
</script>
</body>
</html>