Удалить obsolete server UI и подчистить ссылки

This commit is contained in:
AidarKC 2026-06-04 14:25:59 +04:00
parent de9606519a
commit 59e4156bb9
11 changed files with 4 additions and 2021 deletions

View File

@ -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 деплоя.

View File

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

View File

@ -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-ошибок.

View File

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

View File

@ -1,2 +1,2 @@
client.version=1.2.122 client.version=1.2.123
server.version=1.2.114 server.version=1.2.115

View File

@ -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). Занимает 25 сек.
На страницах сервера публичные и приватные ключи показываются в base58, приватный ключ
хранится как 32-байтовый seed в base58.
- **«JSON ключей»** — вставить keyBundle JSON с тремя парами (rootPair, devicePair, blockchainPair).
На **device-ключе** должно быть достаточно SOL для оплаты транзакции регистрации.
### Обновление настроек сервера (`update-server-pda.html`)
1. Ввести логин и нажать **«Загрузить PDA»** — страница прочитает существующую PDA из Solana и
покажет текущие данные.
2. Изменить адрес сервера или список sync_servers.
3. Выбрать способ ввода ключей:
- **«Из пароля»** — ввести пароль (логин берётся из поля выше);
- **«JSON ключей»** — вставить keyBundle (достаточно rootPair + devicePair).
Blockchain-ключ для обновления не нужен — существующая подпись из PDA переиспользуется.
При ручном вводе допустим base58 seed; если blockchain seed не указан, обновление
использует уже сохранённую подпись последнего блока.
4. Нажать **«Обновить PDA»**.
## Ключевой файл логики
`js/server-pda-core.js` — автономный ES-модуль (без зависимостей на shine-UI).
Экспортирует:
- `readServerPdaData({ login, solanaEndpoint })` — читает и парсит PDA из Solana;
- `registerServerOnSolana({ login, keyBundle, serverAddress, syncServers, ... })`;
- `updateServerOnSolana({ login, keyBundle, serverAddress, syncServers, ... })`;
- `parsePdaData(rawBytes)` — парсит бинарный формат PDA (matches Rust `deserialize_record_from_pda`).
## Связанные документы
- Формат PDA: `shine-solana/shine/doc/SHiNE-user-format-v.1.0.md`
- Деплой Solana-программ: `Dev_Docs/Инициализация_Solana_регистрации/README.md`
- Синхронизация между серверами: `Dev_Docs/Blockchain/sync-between-servers.md`
- Настройки сервера: `SHiNE-server/AGENTS.md`
## Правила при доработке
- Формат Borsh-аргументов в `server-pda-core.js` должен строго соответствовать
`UserMutableFields` в `shine-solana/shine/programs/shine_users/src/users.rs`.
- Бинарный формат PDA в `buildUnsignedRecordBytesServer` должен совпадать с
`serialize_unsigned_record` в Rust.
- При любом изменении формата Solana-программы (`users.rs`) — обновлять `server-pda-core.js`
и документ формата PDA в том же коммите.
- Язык кода и комментариев: русский.

View File

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

View File

@ -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/>
&nbsp;• адрес сервера (например, <code>https://shineup.me/ws</code>);<br/>
&nbsp;• список серверов-партнёров для синхронизации блокчейна и DM;<br/>
&nbsp;• криптографический корневой ключ сервера.<br/><br/>
Клиенты читают PDA прямо из Solana при попытке дозвониться до пользователя или
установить WebSocket-соединение через сервер.
</p>
</div>
<div class="card">
<h2>Что потребуется</h2>
<p style="color:var(--text-muted);font-size:13px;line-height:1.7;">
<strong>Для создания:</strong> полный keyBundle сервера (rootPair + devicePair + blockchainPair),
логин сервера (без точки, не более 20 символов), URL-адрес сервера, Solana-эндпоинт,
достаточный баланс SOL на device-ключе для комиссии.<br/><br/>
<strong>Для обновления:</strong> только rootPair + devicePair (blockchain-ключ не нужен).
</p>
</div>
</div>
</body>
</html>

View File

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

View File

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

View File

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