Удалить obsolete server UI и подчистить ссылки
This commit is contained in:
parent
de9606519a
commit
59e4156bb9
@ -14,7 +14,6 @@
|
|||||||
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
|
||||||
- точка входа `shine-UI/server-ui.html`;
|
- точка входа `shine-UI/server-ui.html`;
|
||||||
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
|
||||||
- Старая папка `shine-server-UI-obsolete/` оставлена только как устаревшая справочная копия и не является актуальной точкой входа.
|
|
||||||
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
|
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
|
||||||
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя.
|
||||||
|
|
||||||
|
|||||||
@ -7,5 +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-UI/server-ui/` — читать `shine-UI/AGENTS.md`, а старую справочную копию при необходимости смотреть в `shine-server-UI-obsolete/AGENTS.md`.
|
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`.
|
||||||
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
4. Загрузку существующей серверной PDA на странице обновления.
|
4. Загрузку существующей серверной PDA на странице обновления.
|
||||||
5. Обновление `server_address` и `sync_servers` только по `root + device` без blockchain-ключа.
|
5. Обновление `server_address` и `sync_servers` только по `root + device` без blockchain-ключа.
|
||||||
6. Корректное чтение нового формата `ServerProfileBlock` через общий PDA-модуль.
|
6. Корректное чтение нового формата `ServerProfileBlock` через общий PDA-модуль.
|
||||||
7. То, что старая папка `shine-server-UI-obsolete/` не используется как актуальная точка входа.
|
7. То, что актуальной точкой входа остаётся `shine-UI/server-ui.html`.
|
||||||
|
|
||||||
- ожидаемый результат:
|
- ожидаемый результат:
|
||||||
1. Новые страницы открываются без JS-ошибок.
|
1. Новые страницы открываются без JS-ошибок.
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
# Фикс native Ed25519 для update server PDA
|
|
||||||
|
|
||||||
- Краткое описание:
|
|
||||||
В `shine_users` восстановлена нативная проверка подписи через встроенные Solana Ed25519-инструкции без прямой Rust-верификации. Для `create_user_pda` и `update_user_pda` зафиксирован порядок инструкций в транзакции: сначала подпись `root_key`, затем подпись `blockchain_public_key`, затем вызов `shine_users`.
|
|
||||||
- Что проверять:
|
|
||||||
1. В `shine-UI/server-ui/update-server-pda.html` загрузить существующий server PDA.
|
|
||||||
2. Ввести правильный пароль, сгенерировать ключи и выполнить `Обновить PDA`.
|
|
||||||
3. Убедиться, что транзакция проходит без `memory allocation failed, out of memory`.
|
|
||||||
4. Отдельно проверить создание server PDA из `shine-UI/server-ui/create-server-pda.html`.
|
|
||||||
5. Отдельно проверить обычную пользовательскую регистрацию через клиентский UI.
|
|
||||||
- Ожидаемый результат:
|
|
||||||
1. `update server PDA` проходит успешно.
|
|
||||||
2. `create server PDA` проходит успешно.
|
|
||||||
3. Регистрация обычного пользователя через тот же JS-модуль работы с PDA тоже проходит успешно.
|
|
||||||
4. Одинаковый общий JS-модуль используется и клиентским UI, и server UI.
|
|
||||||
- Статус: `pending`
|
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.122
|
client.version=1.2.123
|
||||||
server.version=1.2.114
|
server.version=1.2.115
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
# AGENTS.md — shine-server-UI-obsolete
|
|
||||||
|
|
||||||
## Назначение
|
|
||||||
|
|
||||||
`shine-server-UI-obsolete/` — устаревшая автономная веб-панель администратора для управления серверным аккаунтом SHiNE
|
|
||||||
в Solana (регистрация и обновление `user_pda` с флагом `is_server=true`).
|
|
||||||
|
|
||||||
Эта папка оставлена только как справочная копия старой реализации.
|
|
||||||
Актуальная точка входа серверного UI теперь находится в:
|
|
||||||
|
|
||||||
- `shine-UI/server-ui.html`
|
|
||||||
- `shine-UI/server-ui/`
|
|
||||||
|
|
||||||
Никакого бэкенда нет.
|
|
||||||
|
|
||||||
## Структура файлов
|
|
||||||
|
|
||||||
```
|
|
||||||
shine-server-UI-obsolete/
|
|
||||||
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). Занимает 2–5 сек.
|
|
||||||
На страницах сервера публичные и приватные ключи показываются в 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 в том же коммите.
|
|
||||||
- Язык кода и комментариев: русский.
|
|
||||||
@ -1,468 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
<!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/>
|
|
||||||
• адрес сервера (например, <code>https://shineup.me/ws</code>);<br/>
|
|
||||||
• список серверов-партнёров для синхронизации блокчейна и DM;<br/>
|
|
||||||
• криптографический корневой ключ сервера.<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>
|
|
||||||
@ -1,710 +0,0 @@
|
|||||||
// Логика управления серверной 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;
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
@ -1,484 +0,0 @@
|
|||||||
<!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>
|
|
||||||
Loading…
Reference in New Issue
Block a user