Перенести server UI в shine-UI и объединить PDA-модуль

This commit is contained in:
AidarKC 2026-06-03 15:11:26 +04:00
parent c97b3e3ec3
commit d12371b84f
22 changed files with 2006 additions and 753 deletions

View File

@ -11,7 +11,10 @@
## Структура проекта (кратко)
- Серверный код SHiNE находится в папке `SHiNE-server/`.
- Код клиентского UI SHiNE находится в папке `shine-UI/`.
- Веб-панель администратора сервера (управление Solana PDA сервера) — папка `shine-server-UI/`.
- Веб-панель администратора сервера (управление Solana PDA сервера) находится в `shine-UI/`:
- точка входа `shine-UI/server-ui.html`;
- остальные файлы серверного UI — в `shine-UI/server-ui/`.
- Старая папка `shine-server-UI-obsolete/` оставлена только как устаревшая справочная копия и не является актуальной точкой входа.
- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения.
- 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-solana/shine/` — читать `shine-solana/shine/AGENTS.md`.
- При работе внутри `shine-server-UI/` — читать `shine-server-UI/AGENTS.md`.
- При работе внутри `shine-UI/server-ui/` — читать `shine-UI/AGENTS.md`, а старую справочную копию при необходимости смотреть в `shine-server-UI-obsolete/AGENTS.md`.
- При работе внутри `SHiNE-server/` — читать `SHiNE-server/AGENTS.md`.

View File

@ -0,0 +1,24 @@
# Перенос server UI в shine-UI
- краткое описание фичи:
Веб-панель управления серверной Solana PDA перенесена в `shine-UI/` как отдельные страницы.
Новая точка входа: `shine-UI/server-ui.html`.
Общая логика работы с PDA вынесена в единый модуль `shine-UI/js/services/shine-user-pda-service.js`.
- что именно проверять:
1. Открытие `shine-UI/server-ui.html` и переходы на страницы создания и обновления PDA.
2. Генерацию ключей из логина и пароля на странице создания.
3. Ручной ввод base58-ключей и регистрацию серверного PDA.
4. Загрузку существующей серверной PDA на странице обновления.
5. Обновление `server_address` и `sync_servers` только по `root + device` без blockchain-ключа.
6. Корректное чтение нового формата `ServerProfileBlock` через общий PDA-модуль.
7. То, что старая папка `shine-server-UI-obsolete/` не используется как актуальная точка входа.
- ожидаемый результат:
1. Новые страницы открываются без JS-ошибок.
2. Создание серверной PDA проходит через общий модуль и пишет актуальный формат.
3. Обновление серверной PDA переиспользует существующую подпись LastBlockState и не требует blockchain-ключ.
4. Клиентский UI не ломается после перевода общего PDA-слоя на новый формат.
- статус:
pending

View File

@ -31,12 +31,12 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
**Управление серверной PDA выполняется через Web-панель администратора:**
```
shine-server-UI/index.html
shine-UI/server-ui.html
```
Страницы:
- `create-server-pda.html` — первичная регистрация серверного аккаунта;
- `update-server-pda.html` — обновление адреса или списка sync_servers.
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers.
Для регистрации нужен полный keyBundle (root + device + blockchain).
Для обновления — только root + device (blockchain-ключ не нужен).

View File

@ -1,2 +1,2 @@
client.version=1.2.116
server.version=1.2.108
client.version=1.2.117
server.version=1.2.109

View File

@ -52,6 +52,34 @@ export function bytesToBase58(bytes) {
return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join('');
}
export function base58ToBytes(value) {
const text = String(value || '').trim();
if (!text) return new Uint8Array();
const digits = [];
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const index = BASE58_ALPHABET.indexOf(char);
if (index < 0) throw new Error(`Недопустимый символ base58: ${char}`);
let carry = index;
for (let j = 0; j < digits.length; j += 1) {
const acc = (digits[j] * 58) + carry;
digits[j] = acc & 0xff;
carry = acc >> 8;
}
while (carry > 0) {
digits.push(carry & 0xff);
carry >>= 8;
}
}
for (let i = 0; i < text.length && text[i] === '1'; i += 1) {
digits.push(0);
}
return new Uint8Array(digits.reverse());
}
export function randomBase64(byteLen = 32) {
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
return bytesToBase64(bytes);

View File

@ -1,491 +1,7 @@
import { importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
import { SHINE_PAYMENTS_PROGRAM_ID, SHINE_USERS_ECONOMY_CONFIG_SEED, SHINE_USERS_PROGRAM_ID } from '../solana-programs.js';
const MAGIC = 'SHiNE';
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
const LIMIT_STEP = 10_000n;
const UPDATE_USER_PDA_DISCRIMINATOR = new Uint8Array([42, 133, 114, 232, 38, 245, 167, 234]);
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
const BLOCK_TYPE_ROOT_KEY = 1;
const BLOCK_TYPE_DEVICE_KEY = 2;
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
const BLOCK_TYPE_SERVER_PROFILE = 30;
const BLOCK_TYPE_ACCESS_SERVERS = 40;
const BLOCK_TYPE_TRUSTED_STATE = 50;
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
return solanaLibPromise;
}
function pushU32LE(buf, v) {
const n = Number(v) >>> 0;
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
}
function pushU64LE(buf, v) {
const b = BigInt(v);
const lo = Number(b & 0xffffffffn) >>> 0;
const hi = Number((b >> 32n) & 0xffffffffn) >>> 0;
pushU32LE(buf, lo);
pushU32LE(buf, hi);
}
function pushStrU8(buf, value) {
const bytes = new TextEncoder().encode(String(value || ''));
if (bytes.length > 255) throw new Error('Слишком длинная строка для формата U8');
buf.push(bytes.length);
for (const x of bytes) buf.push(x);
}
function pushStrU32(buf, value) {
const bytes = new TextEncoder().encode(String(value || ''));
pushU32LE(buf, bytes.length);
for (const x of bytes) buf.push(x);
}
function pushVecU8(buf, bytes) {
const data = bytes || new Uint8Array();
pushU32LE(buf, data.length);
for (const x of data) buf.push(x);
}
function pushVecStrU32(buf, values) {
const arr = Array.isArray(values) ? values : [];
pushU32LE(buf, arr.length);
for (const s of arr) pushStrU32(buf, s);
}
function makeReader(bytes) {
let o = 0;
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ensure = (n) => { if (o + n > bytes.length) throw new Error('Повреждённый формат PDA'); };
const readU8 = () => { ensure(1); const v = dv.getUint8(o); o += 1; return v; };
const readU16 = () => { ensure(2); const v = dv.getUint16(o, true); o += 2; return v; };
const readU32 = () => { ensure(4); const v = dv.getUint32(o, true); o += 4; return v; };
const readU64 = () => { ensure(8); const v = dv.getBigUint64(o, true); o += 8; return v; };
const readBytes = (n) => { ensure(n); const out = bytes.slice(o, o + n); o += n; return out; };
const readStrU8 = () => {
const len = readU8();
return new TextDecoder().decode(readBytes(len));
};
return { readU8, readU16, readU32, readU64, readBytes, readStrU8 };
}
function parseShineUserPda(dataBytes) {
const r = makeReader(dataBytes);
const magic = new TextDecoder().decode(r.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
r.readU8();
r.readU8();
r.readU16();
const createdAtMs = r.readU64();
const updatedAtMs = r.readU64();
const recordNumber = r.readU32();
const prevRecordHash = r.readBytes(32);
const login = r.readStrU8();
const blocksCount = r.readU8();
const out = {
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
rootKey: null,
deviceKey: null,
blockchain: null,
isServer: false,
serverKey: new Uint8Array(32),
serverAddress: '',
syncServers: [],
accessServers: [],
trustedCount: 0,
};
for (let i = 0; i < blocksCount; i += 1) {
const type = r.readU8();
r.readU8();
if (type === BLOCK_TYPE_ROOT_KEY) { out.rootKey = r.readBytes(32); continue; }
if (type === BLOCK_TYPE_DEVICE_KEY) { out.deviceKey = r.readBytes(32); continue; }
if (type === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = r.readU8();
for (let j = 0; j < count; j += 1) {
const blockchainType = r.readU8();
const blockchainName = r.readStrU8();
const blockchainPublicKey = r.readBytes(32);
const paidLimitBytes = r.readU64();
const usedBytes = r.readU64();
const lastBlockNumber = r.readU32();
const lastBlockHash = r.readBytes(32);
const lastBlockSignature = r.readBytes(64);
const arPresent = r.readU8();
const arweaveTxId = arPresent ? r.readStrU8() : '';
if (!out.blockchain) {
out.blockchain = {
blockchainType,
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
};
}
}
continue;
}
if (type === BLOCK_TYPE_SERVER_PROFILE) {
out.isServer = r.readU8() === 1;
out.serverKey = r.readBytes(32);
out.serverAddress = r.readStrU8();
const syncCount = r.readU8();
out.syncServers = [];
for (let k = 0; k < syncCount; k += 1) out.syncServers.push(r.readStrU8());
continue;
}
if (type === BLOCK_TYPE_ACCESS_SERVERS) {
const accessCount = r.readU8();
out.accessServers = [];
for (let k = 0; k < accessCount; k += 1) out.accessServers.push(r.readStrU8());
continue;
}
if (type === BLOCK_TYPE_TRUSTED_STATE) {
out.trustedCount = r.readU8();
continue;
}
throw new Error(`Неизвестный блок PDA: ${type}`);
}
if (!out.rootKey || !out.deviceKey || !out.blockchain) {
throw new Error('В PDA отсутствуют обязательные блоки');
}
return out;
}
function serializeUnsignedRecordFromState(stateLike) {
const buf = [];
const login = String(stateLike.login || '');
const bch = stateLike.blockchain;
buf.push(0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0);
pushU64LE(buf, stateLike.createdAtMs);
pushU64LE(buf, stateLike.updatedAtMs);
pushU32LE(buf, stateLike.recordNumber);
for (const x of stateLike.prevRecordHash) buf.push(x);
pushStrU8(buf, login);
const blocksCount = stateLike.isServer ? 6 : 5;
buf.push(blocksCount);
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
for (const x of stateLike.rootKey) buf.push(x);
buf.push(BLOCK_TYPE_DEVICE_KEY, 0);
for (const x of stateLike.deviceKey) buf.push(x);
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1);
buf.push(bch.blockchainType);
pushStrU8(buf, bch.blockchainName);
for (const x of bch.blockchainPublicKey) buf.push(x);
pushU64LE(buf, bch.paidLimitBytes);
pushU64LE(buf, bch.usedBytes);
pushU32LE(buf, bch.lastBlockNumber);
for (const x of bch.lastBlockHash) buf.push(x);
for (const x of bch.lastBlockSignature) buf.push(x);
if (String(bch.arweaveTxId || '').trim()) {
buf.push(1);
pushStrU8(buf, bch.arweaveTxId);
} else {
buf.push(0);
}
if (stateLike.isServer) {
buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1);
for (const x of stateLike.serverKey) buf.push(x);
pushStrU8(buf, stateLike.serverAddress);
const sync = Array.isArray(stateLike.syncServers) ? stateLike.syncServers : [];
buf.push(sync.length & 0xff);
for (const s of sync) pushStrU8(buf, s);
}
buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0);
const access = Array.isArray(stateLike.accessServers) ? stateLike.accessServers : [];
buf.push(access.length & 0xff);
for (const s of access) pushStrU8(buf, s);
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, Number(stateLike.trustedCount || 0) & 0xff);
const recLen = buf.length + 64;
buf[7] = recLen & 0xff;
buf[8] = (recLen >>> 8) & 0xff;
return new Uint8Array(buf);
}
function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) {
const buf = [];
for (const x of new TextEncoder().encode(LAST_BLOCK_STATE_PREFIX)) buf.push(x);
pushStrU8(buf, login);
pushStrU8(buf, blockchainName);
pushU32LE(buf, lastBlockNumber);
for (const x of lastBlockHash32) buf.push(x);
pushU64LE(buf, usedBytes);
return new Uint8Array(buf);
}
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
const sigOff = 16;
const pkOff = sigOff + 64;
const msgOff = pkOff + 32;
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;
}
function serializeUpdateUserPdaArgs(args) {
const b = [];
for (const x of UPDATE_USER_PDA_DISCRIMINATOR) b.push(x);
pushStrU32(b, args.login);
for (const x of args.rootKey32) b.push(x);
pushU64LE(b, args.createdAtMs);
pushU64LE(b, args.updatedAtMs);
pushU32LE(b, args.version);
pushVecU8(b, args.prevHash32);
pushU64LE(b, args.additionalLimitBytes);
for (const x of args.deviceKey32) b.push(x);
for (const x of args.blockchainPublicKey32) b.push(x);
pushStrU32(b, args.blockchainName);
pushU64LE(b, args.usedBytes);
pushU32LE(b, args.lastBlockNumber);
pushVecU8(b, args.lastBlockHash32);
pushVecU8(b, args.lastBlockSignature64);
pushStrU32(b, args.arweaveTxId);
b.push(args.isServer ? 1 : 0);
for (const x of args.serverKey32) b.push(x);
pushStrU32(b, args.serverAddress);
pushVecStrU32(b, args.syncServers);
pushVecStrU32(b, args.accessServers);
b.push(Number(args.trustedCount || 0) & 0xff);
pushVecU8(b, args.rootSignature64);
return new Uint8Array(b);
}
function parseUsersEconomyConfig(dataBytes) {
const v = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength);
if (dataBytes.byteLength < 25) throw new Error('Некорректный economy config');
return {
version: v.getUint8(0),
registrationFeeLamports: v.getBigUint64(1, true),
lamportsPerLimitStep: v.getBigUint64(9, true),
startBonusLimit: v.getBigUint64(17, true),
};
}
export async function getShineUsersEconomyConfig({ solanaEndpoint }) {
const endpoint = String(solanaEndpoint || '').trim();
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [economyPda] = solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)],
usersProgram,
);
const ai = await connection.getAccountInfo(economyPda, 'confirmed');
if (!ai?.data) throw new Error('Economy config PDA не найден');
const economy = parseUsersEconomyConfig(ai.data);
return { endpoint, economyPda: economyPda.toBase58(), ...economy };
}
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
const cleanLogin = String(login || '').trim().toLowerCase();
const endpoint = String(solanaEndpoint || '').trim();
if (!cleanLogin) throw new Error('Не указан логин');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const ai = await connection.getAccountInfo(userPda, 'confirmed');
if (!ai?.data) throw new Error('Пользовательский PDA не найден в Solana');
const parsed = parseShineUserPda(ai.data);
const bch = parsed.blockchain;
const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n;
return {
endpoint,
userPda: userPda.toBase58(),
login: parsed.login,
recordNumber: parsed.recordNumber,
paidLimitBytes: bch.paidLimitBytes,
usedBytes: bch.usedBytes,
leftBytes,
lastBlockNumber: bch.lastBlockNumber,
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
}
export async function updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64,
devicePrivatePkcs8B64,
blockchainPrivatePkcs8B64,
additionalLimitBytes = 0n,
nextUsedBytes,
nextLastBlockNumber,
nextLastBlockHashHex,
}) {
const cleanLogin = String(login || '').trim().toLowerCase();
if (!cleanLogin) throw new Error('Не указан логин');
const endpoint = String(solanaEndpoint || '').trim();
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, '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 [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
const userAi = await connection.getAccountInfo(userPda, 'confirmed');
if (!userAi?.data) throw new Error('PDA пользователя не найден');
const current = parseShineUserPda(userAi.data);
const currentBch = current.blockchain;
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
const effectiveLastHash = nextLastBlockHashHex
? Uint8Array.from(String(nextLastBlockHashHex).match(/.{1,2}/g).map((h) => parseInt(h, 16)))
: currentBch.lastBlockHash;
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
const addLimit = BigInt(additionalLimitBytes || 0);
if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит');
if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64);
const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
const updatedAtMs = BigInt(Date.now());
const newPaid = currentBch.paidLimitBytes + addLimit;
const newRecordNumber = current.recordNumber + 1;
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, currentBch.blockchainName, effectiveLastNum, effectiveLastHash, effectiveUsed);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash);
const nextState = {
...current,
updatedAtMs,
recordNumber: newRecordNumber,
prevRecordHash: prevHash,
blockchain: {
...currentBch,
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash: effectiveLastHash,
lastBlockSignature: lastBlockSig64,
},
};
const unsignedNext = serializeUnsignedRecordFromState(nextState);
const unsignedNextHash = await sha256Bytes(unsignedNext);
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
const ixData = serializeUpdateUserPdaArgs({
login: cleanLogin,
rootKey32: current.rootKey,
createdAtMs: current.createdAtMs,
updatedAtMs,
version: newRecordNumber,
prevHash32: prevHash,
additionalLimitBytes: addLimit,
deviceKey32: current.deviceKey,
blockchainPublicKey32: currentBch.blockchainPublicKey,
blockchainName: currentBch.blockchainName,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash32: effectiveLastHash,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
isServer: current.isServer,
serverKey32: current.serverKey,
serverAddress: current.serverAddress,
syncServers: current.syncServers,
accessServers: current.accessServers,
trustedCount: current.trustedCount,
rootSignature64: rootSig64,
});
const edIxRoot = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(rootSig64, current.rootKey, unsignedNextHash),
});
const edIxBch = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(lastBlockSig64, currentBch.blockchainPublicKey, lastBlockStateHash),
});
const updIx = 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: economyPda, isSigner: false, isWritable: false },
],
data: ixData,
});
const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 });
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 131_072 });
const signature = await solana.sendAndConfirmTransaction(
connection,
new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updIx),
[deviceKeypair],
{ commitment: 'confirmed' },
);
return {
signature,
userPda: userPda.toBase58(),
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
lastBlockNumber: effectiveLastNum,
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
}
export function calcLimitTopupPriceLamports(additionalLimitBytes, lamportsPerLimitStep) {
const add = BigInt(additionalLimitBytes || 0);
const pricePerStep = BigInt(lamportsPerLimitStep || 0);
if (add < 0n) throw new Error('Некорректный размер увеличения лимита');
if (add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`);
return (add / LIMIT_STEP) * pricePerStep;
}
export function getLimitStepBytes() {
return LIMIT_STEP;
}
export {
calcLimitTopupPriceLamports,
getLimitStepBytes,
getShineBlockchainUsage,
getShineUsersEconomyConfig,
updateShineUserPdaOnSolana,
} from './shine-user-pda-service.js';

View File

@ -0,0 +1,971 @@
import { base64ToBytes, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
import {
SHINE_LOGIN_GUARD_PROGRAM_ID,
SHINE_PAYMENTS_PROGRAM_ID,
SHINE_USERS_ECONOMY_CONFIG_SEED,
SHINE_USERS_PROGRAM_ID,
} from '../solana-programs.js';
const MAGIC = 'SHiNE';
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
const LIMIT_STEP = 10_000n;
const BLOCKCHAIN_TYPE_MAIN_USER = 1;
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
const UPDATE_USER_PDA_DISCRIMINATOR = new Uint8Array([42, 133, 114, 232, 38, 245, 167, 234]);
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
const BLOCK_TYPE_ROOT_KEY = 1;
const BLOCK_TYPE_DEVICE_KEY = 2;
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
const BLOCK_TYPE_SERVER_PROFILE = 30;
const BLOCK_TYPE_ACCESS_SERVERS = 40;
const BLOCK_TYPE_TRUSTED_STATE = 50;
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
return solanaLibPromise;
}
function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
function pushU32LE(buf, value) {
const n = Number(value) >>> 0;
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
}
function pushU64LE(buf, value) {
const b = BigInt(value);
const lo = Number(b & 0xffffffffn) >>> 0;
const hi = Number((b >> 32n) & 0xffffffffn) >>> 0;
pushU32LE(buf, lo);
pushU32LE(buf, hi);
}
function pushStrU8(buf, value) {
const bytes = new TextEncoder().encode(String(value || ''));
if (bytes.length > 255) throw new Error('Слишком длинная строка для формата U8');
buf.push(bytes.length);
for (const x of bytes) buf.push(x);
}
function pushStrU32(buf, value) {
const bytes = new TextEncoder().encode(String(value || ''));
pushU32LE(buf, bytes.length);
for (const x of bytes) buf.push(x);
}
function pushVecU8(buf, bytes) {
const data = bytes || new Uint8Array();
pushU32LE(buf, data.length);
for (const x of data) buf.push(x);
}
function pushVecStrU32(buf, values) {
const arr = Array.isArray(values) ? values : [];
pushU32LE(buf, arr.length);
for (const value of arr) pushStrU32(buf, value);
}
function makeReader(bytes) {
let offset = 0;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ensure = (len) => {
if (offset + len > bytes.length) throw new Error('Повреждённый формат PDA');
};
const readU8 = () => {
ensure(1);
const value = view.getUint8(offset);
offset += 1;
return value;
};
const readU16 = () => {
ensure(2);
const value = view.getUint16(offset, true);
offset += 2;
return value;
};
const readU32 = () => {
ensure(4);
const value = view.getUint32(offset, true);
offset += 4;
return value;
};
const readU64 = () => {
ensure(8);
const value = view.getBigUint64(offset, true);
offset += 8;
return value;
};
const readBytes = (len) => {
ensure(len);
const out = bytes.slice(offset, offset + len);
offset += len;
return out;
};
const readStrU8 = () => {
const len = readU8();
return new TextDecoder().decode(readBytes(len));
};
return {
readU8,
readU16,
readU32,
readU64,
readBytes,
readStrU8,
get offset() {
return offset;
},
};
}
function parseUsersEconomyConfig(dataBytes) {
const view = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength);
if (dataBytes.byteLength < 25) throw new Error('Некорректный economy config');
return {
version: view.getUint8(0),
registrationFeeLamports: view.getBigUint64(1, true),
lamportsPerLimitStep: view.getBigUint64(9, true),
startBonusLimit: view.getBigUint64(17, true),
};
}
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
const sigOff = 16;
const pkOff = sigOff + 64;
const msgOff = pkOff + 32;
const data = new Uint8Array(msgOff + 32);
const view = new DataView(data.buffer);
data[0] = 1;
data[1] = 0;
view.setUint16(2, sigOff, true);
view.setUint16(4, 0xffff, true);
view.setUint16(6, pkOff, true);
view.setUint16(8, 0xffff, true);
view.setUint16(10, msgOff, true);
view.setUint16(12, 32, true);
view.setUint16(14, 0xffff, true);
data.set(sig64, sigOff);
data.set(pubkey32, pkOff);
data.set(msgHash32, msgOff);
return data;
}
function serializeCreateUserPdaArgs(args) {
const buf = [];
for (const x of CREATE_USER_PDA_DISCRIMINATOR) buf.push(x);
pushStrU32(buf, args.login);
for (const x of args.rootKey32) buf.push(x);
pushU64LE(buf, args.createdAtMs);
pushU64LE(buf, 0n);
for (const x of args.deviceKey32) buf.push(x);
for (const x of args.blockchainPublicKey32) buf.push(x);
pushStrU32(buf, args.blockchainName);
pushU64LE(buf, args.usedBytes);
pushU32LE(buf, args.lastBlockNumber);
pushVecU8(buf, args.lastBlockHash32);
pushVecU8(buf, args.lastBlockSignature64);
pushStrU32(buf, args.arweaveTxId);
buf.push(args.isServer ? 1 : 0);
buf.push(Number(args.addressFormatType || 0) & 0xff);
buf.push(Number(args.addressFormatVersion || 0) & 0xff);
pushStrU32(buf, args.serverAddress);
pushVecStrU32(buf, args.syncServers);
pushVecStrU32(buf, args.accessServers);
buf.push(Number(args.trustedCount || 0) & 0xff);
pushVecU8(buf, args.rootSignature64);
return new Uint8Array(buf);
}
function serializeUpdateUserPdaArgs(args) {
const buf = [];
for (const x of UPDATE_USER_PDA_DISCRIMINATOR) buf.push(x);
pushStrU32(buf, args.login);
for (const x of args.rootKey32) buf.push(x);
pushU64LE(buf, args.createdAtMs);
pushU64LE(buf, args.updatedAtMs);
pushU32LE(buf, args.version);
pushVecU8(buf, args.prevHash32);
pushU64LE(buf, args.additionalLimitBytes);
for (const x of args.deviceKey32) buf.push(x);
for (const x of args.blockchainPublicKey32) buf.push(x);
pushStrU32(buf, args.blockchainName);
pushU64LE(buf, args.usedBytes);
pushU32LE(buf, args.lastBlockNumber);
pushVecU8(buf, args.lastBlockHash32);
pushVecU8(buf, args.lastBlockSignature64);
pushStrU32(buf, args.arweaveTxId);
buf.push(args.isServer ? 1 : 0);
buf.push(Number(args.addressFormatType || 0) & 0xff);
buf.push(Number(args.addressFormatVersion || 0) & 0xff);
pushStrU32(buf, args.serverAddress);
pushVecStrU32(buf, args.syncServers);
pushVecStrU32(buf, args.accessServers);
buf.push(Number(args.trustedCount || 0) & 0xff);
pushVecU8(buf, args.rootSignature64);
return new Uint8Array(buf);
}
function createBlockchainState({
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
}) {
return {
blockchainType: BLOCKCHAIN_TYPE_MAIN_USER,
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
};
}
function createPdaState({
login,
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
rootKey,
deviceKey,
blockchain,
isServer,
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
accessServers,
trustedCount,
}) {
const serverProfile = isServer ? {
addressFormatType: Number(addressFormatType || 0),
addressFormatVersion: Number(addressFormatVersion || 0),
serverAddress: String(serverAddress || ''),
syncServers: Array.isArray(syncServers) ? [...syncServers] : [],
} : null;
return {
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
rootKey,
deviceKey,
blockchain,
isServer: Boolean(isServer),
serverProfile,
serverData: serverProfile,
addressFormatType: serverProfile?.addressFormatType ?? 0,
addressFormatVersion: serverProfile?.addressFormatVersion ?? 0,
serverAddress: serverProfile?.serverAddress ?? '',
syncServers: serverProfile?.syncServers ? [...serverProfile.syncServers] : [],
accessServers: Array.isArray(accessServers) ? [...accessServers] : [],
trustedCount: Number(trustedCount || 0) & 0xff,
};
}
function encodeOptionalArweave(buf, arweaveTxId) {
const value = String(arweaveTxId || '').trim();
if (value) {
buf.push(1);
pushStrU8(buf, value);
} else {
buf.push(0);
}
}
export function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber = 0, lastBlockHash32 = new Uint8Array(32), usedBytes = 0n) {
const buf = [];
for (const x of new TextEncoder().encode(LAST_BLOCK_STATE_PREFIX)) buf.push(x);
pushStrU8(buf, login);
pushStrU8(buf, blockchainName);
pushU32LE(buf, lastBlockNumber);
for (const x of lastBlockHash32) buf.push(x);
pushU64LE(buf, usedBytes);
return new Uint8Array(buf);
}
export function parseShineUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
reader.readU8();
reader.readU8();
const recordLen = reader.readU16();
if (recordLen < 9 + 64 || recordLen > bytes.length) throw new Error('Некорректный record_len');
const createdAtMs = reader.readU64();
const updatedAtMs = reader.readU64();
const recordNumber = reader.readU32();
const prevRecordHash = reader.readBytes(32);
const login = reader.readStrU8();
const blocksCount = reader.readU8();
let rootKey = null;
let deviceKey = null;
let blockchain = null;
let isServer = false;
let addressFormatType = 0;
let addressFormatVersion = 0;
let serverAddress = '';
let syncServers = [];
let accessServers = [];
let trustedCount = 0;
for (let i = 0; i < blocksCount; i += 1) {
const blockType = reader.readU8();
reader.readU8();
if (blockType === BLOCK_TYPE_ROOT_KEY) {
rootKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_DEVICE_KEY) {
deviceKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = reader.readU8();
for (let j = 0; j < count; j += 1) {
const blockchainType = reader.readU8();
const blockchainName = reader.readStrU8();
const blockchainPublicKey = reader.readBytes(32);
const paidLimitBytes = reader.readU64();
const usedBytes = reader.readU64();
const lastBlockNumber = reader.readU32();
const lastBlockHash = reader.readBytes(32);
const lastBlockSignature = reader.readBytes(64);
const arweavePresent = reader.readU8();
const arweaveTxId = arweavePresent === 1 ? reader.readStrU8() : '';
if (!blockchain) {
blockchain = {
blockchainType,
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
};
}
}
continue;
}
if (blockType === BLOCK_TYPE_SERVER_PROFILE) {
isServer = reader.readU8() === 1;
if (isServer) {
addressFormatType = reader.readU8();
addressFormatVersion = reader.readU8();
serverAddress = reader.readStrU8();
const syncCount = reader.readU8();
syncServers = [];
for (let j = 0; j < syncCount; j += 1) syncServers.push(reader.readStrU8());
}
continue;
}
if (blockType === BLOCK_TYPE_ACCESS_SERVERS) {
const accessCount = reader.readU8();
accessServers = [];
for (let j = 0; j < accessCount; j += 1) accessServers.push(reader.readStrU8());
continue;
}
if (blockType === BLOCK_TYPE_TRUSTED_STATE) {
trustedCount = reader.readU8();
continue;
}
throw new Error(`Неизвестный блок PDA: ${blockType}`);
}
if (!rootKey || !deviceKey || !blockchain) {
throw new Error('В PDA отсутствуют обязательные блоки');
}
const signature = bytes.slice(reader.offset, reader.offset + 64);
const unsignedBytes = bytes.slice(0, recordLen - 64);
const state = createPdaState({
login,
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
rootKey,
deviceKey,
blockchain,
isServer,
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
accessServers,
trustedCount,
});
return {
...state,
recordLen,
unsignedBytes,
signature,
};
}
export function serializeUnsignedRecordFromState(stateLike) {
const state = createPdaState({
login: stateLike.login,
createdAtMs: stateLike.createdAtMs,
updatedAtMs: stateLike.updatedAtMs,
recordNumber: stateLike.recordNumber,
prevRecordHash: stateLike.prevRecordHash,
rootKey: stateLike.rootKey,
deviceKey: stateLike.deviceKey,
blockchain: stateLike.blockchain,
isServer: stateLike.isServer,
addressFormatType: stateLike.addressFormatType ?? stateLike.serverProfile?.addressFormatType,
addressFormatVersion: stateLike.addressFormatVersion ?? stateLike.serverProfile?.addressFormatVersion,
serverAddress: stateLike.serverAddress ?? stateLike.serverProfile?.serverAddress,
syncServers: stateLike.syncServers ?? stateLike.serverProfile?.syncServers,
accessServers: stateLike.accessServers,
trustedCount: stateLike.trustedCount,
});
const buf = [0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0];
pushU64LE(buf, state.createdAtMs);
pushU64LE(buf, state.updatedAtMs);
pushU32LE(buf, state.recordNumber);
for (const x of state.prevRecordHash) buf.push(x);
pushStrU8(buf, state.login);
buf.push(state.isServer ? 6 : 5);
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
for (const x of state.rootKey) buf.push(x);
buf.push(BLOCK_TYPE_DEVICE_KEY, 0);
for (const x of state.deviceKey) buf.push(x);
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1, state.blockchain.blockchainType);
pushStrU8(buf, state.blockchain.blockchainName);
for (const x of state.blockchain.blockchainPublicKey) buf.push(x);
pushU64LE(buf, state.blockchain.paidLimitBytes);
pushU64LE(buf, state.blockchain.usedBytes);
pushU32LE(buf, state.blockchain.lastBlockNumber);
for (const x of state.blockchain.lastBlockHash) buf.push(x);
for (const x of state.blockchain.lastBlockSignature) buf.push(x);
encodeOptionalArweave(buf, state.blockchain.arweaveTxId);
if (state.isServer) {
buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1);
buf.push(state.addressFormatType & 0xff);
buf.push(state.addressFormatVersion & 0xff);
pushStrU8(buf, state.serverAddress);
buf.push(state.syncServers.length & 0xff);
for (const loginValue of state.syncServers) pushStrU8(buf, loginValue);
}
buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0, state.accessServers.length & 0xff);
for (const loginValue of state.accessServers) pushStrU8(buf, loginValue);
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, state.trustedCount & 0xff);
const recordLen = buf.length + 64;
buf[7] = recordLen & 0xff;
buf[8] = (recordLen >>> 8) & 0xff;
return new Uint8Array(buf);
}
export async function getShineUsersEconomyConfig({ solanaEndpoint }) {
const endpoint = String(solanaEndpoint || '').trim();
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [economyPda] = solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)],
usersProgram,
);
const accountInfo = await connection.getAccountInfo(economyPda, 'confirmed');
if (!accountInfo?.data) throw new Error('Economy config PDA не найден');
return { endpoint, economyPda: economyPda.toBase58(), ...parseUsersEconomyConfig(accountInfo.data) };
}
export async function readShineUserPda({ login, solanaEndpoint }) {
const cleanLogin = normalizeLogin(login);
const endpoint = String(solanaEndpoint || '').trim();
if (!cleanLogin) throw new Error('Не указан логин');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const accountInfo = await connection.getAccountInfo(userPda, 'confirmed');
if (!accountInfo?.data) throw new Error(`PDA не найдена для логина «${cleanLogin}»`);
return {
...parseShineUserPda(accountInfo.data),
userPda: userPda.toBase58(),
pdaAddress: userPda.toBase58(),
endpoint,
};
}
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
const parsed = await readShineUserPda({ login, solanaEndpoint });
const bch = parsed.blockchain;
const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n;
return {
endpoint: parsed.endpoint,
userPda: parsed.userPda,
login: parsed.login,
recordNumber: parsed.recordNumber,
paidLimitBytes: bch.paidLimitBytes,
usedBytes: bch.usedBytes,
leftBytes,
lastBlockNumber: bch.lastBlockNumber,
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
}
function parseHex32(value) {
const clean = String(value || '').trim().toLowerCase();
if (!clean) return null;
if (!/^[0-9a-f]+$/.test(clean) || clean.length !== 64) {
throw new Error('last block hash должен быть 32 байта в hex');
}
const out = new Uint8Array(32);
for (let i = 0; i < 32; i += 1) {
out[i] = parseInt(clean.slice(i * 2, (i * 2) + 2), 16);
}
return out;
}
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const cleanLogin = normalizeLogin(login);
const endpoint = String(solanaEndpoint || '').trim();
if (!cleanLogin) throw new Error('Не указан логин');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, '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 [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const [economyConfigPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64);
const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
return {
cleanLogin,
endpoint,
solana,
connection,
usersProgram,
paymentsProgram,
loginGuardProgram,
ed25519Program,
sysvarInstructions,
userPda,
economyConfigPda,
inflowVault,
rootKey32,
blockchainKey32,
deviceKey32,
rootPrivKey,
bchPrivKey,
deviceKeypair,
};
}
async function createShineUserPdaOnSolana({
login,
keyBundle,
solanaEndpoint,
isServer = false,
addressFormatType = 0,
addressFormatVersion = 0,
serverAddress = '',
syncServers = [],
accessServers = [],
}) {
const ctx = await buildCreateContext({ login, keyBundle, solanaEndpoint });
const ecoAccount = await ctx.connection.getAccountInfo(ctx.economyConfigPda);
if (!ecoAccount?.data) {
throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.');
}
const cleanLogin = ctx.cleanLogin;
const blockchainName = `${cleanLogin}-001`;
const zeroHash32 = new Uint8Array(32);
const createdAtMs = BigInt(Date.now());
const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit;
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, zeroHash32, 0n);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash);
const initialState = createPdaState({
login: cleanLogin,
createdAtMs,
updatedAtMs: createdAtMs,
recordNumber: 0,
prevRecordHash: zeroHash32,
rootKey: ctx.rootKey32,
deviceKey: ctx.deviceKey32,
blockchain: createBlockchainState({
blockchainName,
blockchainPublicKey: ctx.blockchainKey32,
paidLimitBytes: startBonusLimit,
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash: zeroHash32,
lastBlockSignature: lastBlockSig64,
arweaveTxId: '',
}),
isServer,
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
accessServers,
trustedCount: 0,
});
const unsignedRecord = serializeUnsignedRecordFromState(initialState);
const unsignedHash = await sha256Bytes(unsignedRecord);
const rootSig64 = await signBytes(ctx.rootPrivKey, unsignedHash);
const ixData = serializeCreateUserPdaArgs({
login: cleanLogin,
rootKey32: ctx.rootKey32,
createdAtMs,
deviceKey32: ctx.deviceKey32,
blockchainPublicKey32: ctx.blockchainKey32,
blockchainName,
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash32: zeroHash32,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: '',
isServer,
addressFormatType: isServer ? addressFormatType : 0,
addressFormatVersion: isServer ? addressFormatVersion : 0,
serverAddress: isServer ? serverAddress : '',
syncServers: isServer ? syncServers : [],
accessServers,
trustedCount: 0,
rootSignature64: rootSig64,
});
const ed25519RootIx = new ctx.solana.TransactionInstruction({
programId: ctx.ed25519Program,
keys: [],
data: buildEd25519IxData(rootSig64, ctx.rootKey32, unsignedHash),
});
const ed25519BchIx = new ctx.solana.TransactionInstruction({
programId: ctx.ed25519Program,
keys: [],
data: buildEd25519IxData(lastBlockSig64, ctx.blockchainKey32, lastBlockStateHash),
});
const createIx = new ctx.solana.TransactionInstruction({
programId: ctx.usersProgram,
keys: [
{ pubkey: ctx.deviceKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: ctx.userPda, isSigner: false, isWritable: true },
{ pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
{ pubkey: ctx.sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: ctx.economyConfigPda, isSigner: false, isWritable: false },
{ pubkey: ctx.loginGuardProgram, isSigner: false, isWritable: false },
],
data: ixData,
});
const signature = await ctx.solana.sendAndConfirmTransaction(
ctx.connection,
new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx),
[ctx.deviceKeypair],
{ commitment: 'confirmed' },
);
return {
signature,
userPda: ctx.userPda.toBase58(),
pdaAddress: ctx.userPda.toBase58(),
blockchainName,
};
}
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
return createShineUserPdaOnSolana({
login,
keyBundle,
solanaEndpoint,
isServer: false,
accessServers: ['shineup.me'],
});
}
export async function registerServerOnSolana({
login,
keyBundle,
serverAddress,
addressFormatType = 1,
addressFormatVersion = 0,
syncServers = [],
accessServers = [],
solanaEndpoint,
}) {
return createShineUserPdaOnSolana({
login,
keyBundle,
solanaEndpoint,
isServer: true,
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
accessServers,
});
}
export async function updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64,
devicePrivatePkcs8B64,
blockchainPrivatePkcs8B64,
additionalLimitBytes = 0n,
nextUsedBytes,
nextLastBlockNumber,
nextLastBlockHashHex,
serverProfile,
accessServers,
trustedCount,
}) {
const current = await readShineUserPda({ login, solanaEndpoint });
const cleanLogin = current.login;
const endpoint = current.endpoint;
const currentBch = current.blockchain;
const addLimit = BigInt(additionalLimitBytes || 0);
if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит');
if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash;
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, '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 [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
const lastBlockStateBytes = buildLastBlockStateBytes(
cleanLogin,
currentBch.blockchainName,
effectiveLastNum,
effectiveLastHash,
effectiveUsed,
);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const blockchainStateWillChange = (
nextUsedBytes != null
|| nextLastBlockNumber != null
|| nextLastBlockHashHex != null
);
let lastBlockSig64 = currentBch.lastBlockSignature;
if (blockchainPrivatePkcs8B64) {
const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64);
lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash);
} else if (blockchainStateWillChange) {
throw new Error('Для изменения last block state нужен blockchain-ключ');
}
const updatedAtMs = BigInt(Date.now());
const newPaid = currentBch.paidLimitBytes + addLimit;
const newRecordNumber = current.recordNumber + 1;
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
const nextServerProfile = serverProfile
? {
addressFormatType: Number(serverProfile.addressFormatType ?? current.addressFormatType ?? 0),
addressFormatVersion: Number(serverProfile.addressFormatVersion ?? current.addressFormatVersion ?? 0),
serverAddress: String(serverProfile.serverAddress ?? current.serverAddress ?? ''),
syncServers: Array.isArray(serverProfile.syncServers) ? [...serverProfile.syncServers] : [...current.syncServers],
}
: current.serverProfile;
const nextState = createPdaState({
login: cleanLogin,
createdAtMs: current.createdAtMs,
updatedAtMs,
recordNumber: newRecordNumber,
prevRecordHash: prevHash,
rootKey: current.rootKey,
deviceKey: current.deviceKey,
blockchain: createBlockchainState({
blockchainName: currentBch.blockchainName,
blockchainPublicKey: currentBch.blockchainPublicKey,
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash: effectiveLastHash,
lastBlockSignature: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
}),
isServer: Boolean(nextServerProfile),
addressFormatType: nextServerProfile?.addressFormatType ?? 0,
addressFormatVersion: nextServerProfile?.addressFormatVersion ?? 0,
serverAddress: nextServerProfile?.serverAddress ?? '',
syncServers: nextServerProfile?.syncServers ?? [],
accessServers: accessServers == null ? current.accessServers : accessServers,
trustedCount: trustedCount == null ? current.trustedCount : trustedCount,
});
const unsignedNext = serializeUnsignedRecordFromState(nextState);
const unsignedNextHash = await sha256Bytes(unsignedNext);
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
const ixData = serializeUpdateUserPdaArgs({
login: cleanLogin,
rootKey32: current.rootKey,
createdAtMs: current.createdAtMs,
updatedAtMs,
version: newRecordNumber,
prevHash32: prevHash,
additionalLimitBytes: addLimit,
deviceKey32: current.deviceKey,
blockchainPublicKey32: currentBch.blockchainPublicKey,
blockchainName: currentBch.blockchainName,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash32: effectiveLastHash,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
isServer: nextState.isServer,
addressFormatType: nextState.addressFormatType,
addressFormatVersion: nextState.addressFormatVersion,
serverAddress: nextState.serverAddress,
syncServers: nextState.syncServers,
accessServers: nextState.accessServers,
trustedCount: nextState.trustedCount,
rootSignature64: rootSig64,
});
const edIxRoot = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(rootSig64, current.rootKey, unsignedNextHash),
});
const edIxBch = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(lastBlockSig64, currentBch.blockchainPublicKey, lastBlockStateHash),
});
const updateIx = 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: economyPda, isSigner: false, isWritable: false },
],
data: ixData,
});
const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 });
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 131_072 });
const signature = await solana.sendAndConfirmTransaction(
connection,
new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updateIx),
[deviceKeypair],
{ commitment: 'confirmed' },
);
return {
signature,
userPda: userPda.toBase58(),
pdaAddress: userPda.toBase58(),
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
lastBlockNumber: effectiveLastNum,
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
}
export async function updateServerOnSolana({
login,
keyBundle,
serverAddress,
addressFormatType,
addressFormatVersion,
syncServers,
solanaEndpoint,
}) {
return updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
devicePrivatePkcs8B64: keyBundle.devicePair.privatePkcs8B64,
serverProfile: {
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
},
});
}
export function calcLimitTopupPriceLamports(additionalLimitBytes, lamportsPerLimitStep) {
const add = BigInt(additionalLimitBytes || 0);
const pricePerStep = BigInt(lamportsPerLimitStep || 0);
if (add < 0n) throw new Error('Некорректный размер увеличения лимита');
if (add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`);
return (add / LIMIT_STEP) * pricePerStep;
}
export function getLimitStepBytes() {
return LIMIT_STEP;
}

View File

@ -1,16 +1,11 @@
import { sha256Bytes, signBytes, importPkcs8Ed25519, base64ToBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
import { registerUserOnSolana as registerUserOnSolanaShared } from './shine-user-pda-service.js';
import {
SHINE_USERS_PROGRAM_ID,
SHINE_PAYMENTS_PROGRAM_ID,
SHINE_LOGIN_GUARD_PROGRAM_ID,
} from '../solana-programs.js';
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
const CLASSIFY_LOGIN_DISCRIMINATOR = new Uint8Array([112, 97, 152, 32, 255, 73, 108, 86]);
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
let solanaLibPromise = null;
function loadSolanaLib() {
@ -23,169 +18,19 @@ function pushU32LE(buf, v) {
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); }
}
// Matches Rust serialize_last_block_state (initial zero state)
function buildLastBlockStateBytes(login, blockchainName) {
const enc = new TextEncoder();
const prefix = enc.encode('SHiNE_LAST_BLOCK');
const loginB = enc.encode(login);
const bchB = enc.encode(blockchainName);
const buf = [];
for (const x of prefix) buf.push(x);
buf.push(loginB.length);
for (const x of loginB) buf.push(x);
buf.push(bchB.length);
for (const x of bchB) buf.push(x);
pushU32LE(buf, 0); // last_block_number = 0
for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32]
pushU64LE(buf, 0n); // used_bytes = 0
return new Uint8Array(buf);
}
// Matches Rust serialize_unsigned_record for initial registration
function buildUnsignedRecordBytes(
login, createdAtMs, rootKey32, deviceKey32, blockchainKey32,
blockchainName, paidLimitBytes, lastBlockSig64,
) {
const enc = new TextEncoder();
const loginB = enc.encode(login);
const bchB = enc.encode(blockchainName);
const accessB = enc.encode('shineup.me');
const buf = [];
// Fixed header: MAGIC(5) + FORMAT_MAJOR(1) + FORMAT_MINOR(1) + record_len_placeholder(2)
buf.push(0x53, 0x48, 0x69, 0x4E, 0x45, 1, 0, 0, 0); // indices 0..8
pushU64LE(buf, createdAtMs); // created_at_ms
pushU64LE(buf, createdAtMs); // updated_at_ms = same
pushU32LE(buf, 0); // record_number = 0
for (let i = 0; i < 32; i++) buf.push(0); // prev_record_hash = [0;32]
buf.push(loginB.length);
for (const x of loginB) buf.push(x);
buf.push(5); // blocks_count (non-server)
// 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)
buf.push(3, 0, 1, 1); // type, ver, count=1, blockchain_type=1(MAIN_USER)
buf.push(bchB.length);
for (const x of bchB) buf.push(x);
for (const x of blockchainKey32) buf.push(x);
pushU64LE(buf, paidLimitBytes); // paid_limit_bytes
pushU64LE(buf, 0n); // used_bytes = 0
pushU32LE(buf, 0); // last_block_number = 0
for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32]
for (const x of lastBlockSig64) buf.push(x); // last_block_signature
buf.push(0); // arweave_present = 0
// AccessServersBlock (type=40, ver=0)
buf.push(40, 0, 1, accessB.length);
for (const x of accessB) buf.push(x);
// TrustedStateBlock (type=50, ver=0, trusted_count=0)
buf.push(50, 0, 0);
// Patch record_len at indices 7-8: total = buf.length + 64 (signature)
const recLen = buf.length + 64;
buf[7] = recLen & 0xFF;
buf[8] = (recLen >> 8) & 0xFF;
return new Uint8Array(buf);
}
// Builds Ed25519 program instruction data for one signature
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
const sigOff = 16;
const pkOff = sigOff + 64; // 80
const msgOff = pkOff + 32; // 112
const data = new Uint8Array(msgOff + 32); // 144 bytes total
const v = new DataView(data.buffer);
data[0] = 1; data[1] = 0; // num_signatures=1, padding
v.setUint16(2, sigOff, true);
v.setUint16(4, 0xFFFF, true); // same instruction
v.setUint16(6, pkOff, true);
v.setUint16(8, 0xFFFF, true);
v.setUint16(10, msgOff, true);
v.setUint16(12, 32, true); // message_data_size = 32
v.setUint16(14, 0xFFFF, true);
data.set(sig64, sigOff);
data.set(pubkey32, pkOff);
data.set(msgHash32, msgOff);
return data;
}
function readStartBonusLimit(data) {
// Borsh: version(u8=1) + reg_fee(u64=8) + lamports_per_step(u64=8) + start_bonus_limit(u64=8)
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
return v.getBigUint64(17, true);
}
function serializeCreateUserPdaArgs(
login, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
blockchainName, lastBlockSig64, 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(0n); // used_bytes
b.u32(0); // last_block_number
b.vecU8(new Uint8Array(32)); // last_block_hash
b.vecU8(lastBlockSig64); // last_block_signature
b.str(''); // arweave_tx_id
b.bool(false); // is_server
b.u8(0); // address_format_type
b.u8(0); // address_format_version
b.str(''); // server_address
b.vecStr([]); // sync_servers
b.vecStr(['shineup.me']); // access_servers
b.u8(0); // trusted_count
b.vecU8(rootSig64); // signature
return b.result();
}
function serializeClassifyLoginArgs(login) {
const b = new BorshBuf();
b.raw(CLASSIFY_LOGIN_DISCRIMINATOR);
@ -284,98 +129,5 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
}
export async function registerUserOnSolana({ login, keyBundle, 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 = login.toLowerCase();
const blockchainName = `${loginNorm}-001`;
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 rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
const ecoAccount = await connection.getAccountInfo(economyConfigPda);
if (!ecoAccount) {
throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.');
}
const startBonusLimit = readStartBonusLimit(ecoAccount.data);
const createdAtMs = BigInt(Date.now());
// Sign LastBlockState with blockchain key
const lbsBytes = buildLastBlockStateBytes(loginNorm, blockchainName);
const lbsHash = await sha256Bytes(lbsBytes);
const lastBlockSig64 = await signBytes(bchPrivKey, lbsHash);
// Build and sign unsigned PDA record with root key
const unsignedRecord = buildUnsignedRecordBytes(
loginNorm, createdAtMs, rootKey32, deviceKey32, blockchainKey32,
blockchainName, startBonusLimit, lastBlockSig64,
);
const unsignedHash = await sha256Bytes(unsignedRecord);
const rootSig64 = await signBytes(rootPrivKey, unsignedHash);
const ixData = serializeCreateUserPdaArgs(
loginNorm, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
blockchainName, lastBlockSig64, rootSig64,
);
// Ed25519 instructions must precede create_user_pda
const ed25519RootIx = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash),
});
const ed25519BchIx = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash),
});
const createUserIx = 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 sig = await solana.sendAndConfirmTransaction(
connection,
new solana.Transaction().add(ed25519RootIx, ed25519BchIx, createUserIx),
[deviceKeypair],
{ commitment: 'confirmed' },
);
return { signature: sig, blockchainName };
return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint });
}

58
shine-UI/server-ui.html Normal file
View File

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

View File

@ -0,0 +1,127 @@
<!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="../server-ui.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" src="./js/create-server-pda-page.js"></script>
</body>
</html>

View File

@ -0,0 +1,97 @@
import { registerServerOnSolana } from '../../js/services/shine-user-pda-service.js';
import {
$,
buildKeyBundleFromForm,
clearGenMessage,
clearStatus,
deriveKeyBundleFromPassword,
fillKeyFields,
parseLoginList,
setGenMessage,
setStatus,
setupPasswordEye,
updateSolAddress,
validateLoginOrThrow,
wireDeviceAddressPreview,
} from './server-ui-shared.js';
const fieldMap = {
masterSecret: 'masterSecret',
rootPub: 'rootPub',
rootPriv: 'rootPriv',
bchPub: 'bchPub',
bchPriv: 'bchPriv',
devPub: 'devPub',
devPriv: 'devPriv',
solBox: 'solBox',
solAdr: 'solAdr',
};
setupPasswordEye($('btnEye'), $('password'));
wireDeviceAddressPreview(fieldMap);
$('btnGen').addEventListener('click', async () => {
clearGenMessage($('genMsg'));
clearStatus($('status'));
$('btnGen').disabled = true;
try {
const login = validateLoginOrThrow($('login').value);
const password = $('password').value;
const { masterSecret32, keyBundle } = await deriveKeyBundleFromPassword({
login,
password,
});
fillKeyFields(fieldMap, keyBundle, masterSecret32);
updateSolAddress(fieldMap);
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля.', 'ok');
} catch (error) {
setGenMessage($('genMsg'), error?.message || String(error), 'err');
} finally {
$('btnGen').disabled = false;
}
});
$('btnCreate').addEventListener('click', async () => {
clearStatus($('status'));
clearGenMessage($('genMsg'));
$('btnCreate').disabled = true;
try {
const login = validateLoginOrThrow($('login').value);
const endpoint = String($('endpoint').value || '').trim();
if (!endpoint) throw new Error('Укажите Solana endpoint');
const serverAddress = String($('serverAddress').value || '').trim();
if (!serverAddress) throw new Error('Укажите адрес сервера');
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap);
$('rootPub').value = normalized.rootPubB58;
$('rootPriv').value = normalized.rootPrivB58;
$('bchPub').value = normalized.bchPubB58;
$('bchPriv').value = normalized.bchPrivB58;
$('devPub').value = normalized.devPubB58;
$('devPriv').value = normalized.devPrivB58;
updateSolAddress(fieldMap);
setStatus($('status'), 'Отправка create_user_pda в Solana...', 'info');
const result = await registerServerOnSolana({
login,
keyBundle,
serverAddress,
syncServers: parseLoginList($('syncServers').value),
accessServers: parseLoginList($('accessServers').value),
solanaEndpoint: endpoint,
});
setStatus(
$('status'),
`✓ Сервер зарегистрирован!\n\nЛогин: ${login}\nPDA: ${result.pdaAddress}\nBlockchain: ${result.blockchainName}\nТранзакция: ${result.signature}`,
'success',
);
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnCreate').disabled = false;
}
});
document.body.dataset.ready = '1';

View File

@ -0,0 +1,196 @@
import {
base58ToBytes,
base64ToBytes,
bytesToBase58,
bytesToBase64,
deriveEd25519FromMasterSecret,
deriveMasterSecretFromPassword,
publicKeyB64FromPkcs8Ed25519,
} from '../../js/services/crypto-utils.js';
const LOGIN_RE = /^[a-z0-9_]{1,20}$/;
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
export function $(id) {
return document.getElementById(id);
}
export function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
export function validateLoginOrThrow(login) {
const clean = normalizeLogin(login);
if (!LOGIN_RE.test(clean)) {
throw new Error('Логин должен содержать только a-z, 0-9, _ и быть длиной 1..20 символов');
}
return clean;
}
export function parseLoginList(text) {
return String(text || '')
.split(/\r?\n/)
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
}
export function formatBigInt(value) {
return BigInt(value || 0n).toString(10);
}
export function formatTimestamp(value) {
const ts = Number(BigInt(value || 0n));
if (!Number.isFinite(ts) || ts <= 0) return '—';
return new Date(ts).toLocaleString('ru-RU');
}
export function setStatus(node, text, kind = 'info') {
node.className = `status ${kind}`;
node.textContent = String(text || '');
}
export function clearStatus(node) {
node.className = 'status';
node.textContent = '';
}
export function setGenMessage(node, text, kind) {
node.className = `gen-msg ${kind}`;
node.textContent = String(text || '');
}
export function clearGenMessage(node) {
node.className = 'gen-msg';
node.textContent = '';
}
export function setupPasswordEye(button, input) {
button.addEventListener('click', () => {
const nextType = input.type === 'password' ? 'text' : 'password';
input.type = nextType;
button.textContent = nextType === 'password' ? 'Показать' : 'Скрыть';
});
}
function ensure32Bytes(bytes) {
const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (input.length > 32) throw new Error(`Ожидалось максимум 32 байта, получено ${input.length}`);
if (input.length === 32) return input;
const out = new Uint8Array(32);
out.set(input, 32 - input.length);
return out;
}
function pkcs8FromSeed32(seed32) {
const seed = ensure32Bytes(seed32);
const out = new Uint8Array(ED25519_PKCS8_PREFIX.length + seed.length);
out.set(ED25519_PKCS8_PREFIX, 0);
out.set(seed, ED25519_PKCS8_PREFIX.length);
return out;
}
async function pairFromSeedBase58(seedB58, explicitPubB58) {
const seed32 = ensure32Bytes(base58ToBytes(seedB58));
const privatePkcs8B64 = bytesToBase64(pkcs8FromSeed32(seed32));
const publicKeyB64 = await publicKeyB64FromPkcs8Ed25519(privatePkcs8B64);
const actualPubB58 = bytesToBase58(base64ToBytes(publicKeyB64));
const expectedPubB58 = String(explicitPubB58 || '').trim();
if (expectedPubB58 && actualPubB58 !== expectedPubB58) {
throw new Error(`Публичный ключ не совпадает с приватным seed: ${expectedPubB58}`);
}
return {
publicKeyB64,
privatePkcs8B64,
publicKeyB58: actualPubB58,
privateSeedB58: bytesToBase58(seed32),
};
}
export async function buildKeyBundleFromForm(fieldMap, options = {}) {
const requireBlockchain = options.requireBlockchain !== false;
const root = await pairFromSeedBase58($(fieldMap.rootPriv).value, $(fieldMap.rootPub).value);
const device = await pairFromSeedBase58($(fieldMap.devPriv).value, $(fieldMap.devPub).value);
const blockchainPriv = String($(fieldMap.bchPriv).value || '').trim();
const blockchainPub = String($(fieldMap.bchPub).value || '').trim();
const hasBlockchainInput = Boolean(blockchainPriv || blockchainPub);
let blockchain = null;
if (requireBlockchain || hasBlockchainInput) {
blockchain = await pairFromSeedBase58(blockchainPriv, blockchainPub);
}
return {
keyBundle: {
rootPair: { publicKeyB64: root.publicKeyB64, privatePkcs8B64: root.privatePkcs8B64 },
blockchainPair: blockchain
? { publicKeyB64: blockchain.publicKeyB64, privatePkcs8B64: blockchain.privatePkcs8B64 }
: null,
devicePair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 },
},
normalized: {
rootPubB58: root.publicKeyB58,
rootPrivB58: root.privateSeedB58,
bchPubB58: blockchain?.publicKeyB58 || '',
bchPrivB58: blockchain?.privateSeedB58 || '',
devPubB58: device.publicKeyB58,
devPrivB58: device.privateSeedB58,
},
};
}
export async function deriveKeyBundleFromPassword({ login, password, onProgress }) {
const cleanLogin = validateLoginOrThrow(login);
const cleanPassword = String(password ?? '');
if (!cleanPassword) throw new Error('Введите пароль');
const masterSecret32 = await deriveMasterSecretFromPassword(cleanPassword, {
login: cleanLogin,
onProgress,
});
const [rootPair, blockchainPair, devicePair] = await Promise.all([
deriveEd25519FromMasterSecret(masterSecret32, 'root.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'bch.key'),
deriveEd25519FromMasterSecret(masterSecret32, 'dev.key'),
]);
return {
masterSecret32,
keyBundle: { rootPair, blockchainPair, devicePair },
};
}
export function fillKeyFields(fieldMap, keyBundle, masterSecret32) {
if (masterSecret32) {
$(fieldMap.masterSecret).value = bytesToBase58(masterSecret32);
}
$(fieldMap.rootPub).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64));
$(fieldMap.rootPriv).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.privatePkcs8B64).slice(-32));
$(fieldMap.bchPub).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64));
$(fieldMap.bchPriv).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.privatePkcs8B64).slice(-32));
$(fieldMap.devPub).value = bytesToBase58(base64ToBytes(keyBundle.devicePair.publicKeyB64));
$(fieldMap.devPriv).value = bytesToBase58(base64ToBytes(keyBundle.devicePair.privatePkcs8B64).slice(-32));
}
export function updateSolAddress(fieldMap) {
const box = $(fieldMap.solBox);
const label = $(fieldMap.solAdr);
const pubB58 = String($(fieldMap.devPub).value || '').trim();
if (!pubB58) {
box.classList.remove('show');
label.textContent = '';
return;
}
try {
ensure32Bytes(base58ToBytes(pubB58));
label.textContent = pubB58;
box.classList.add('show');
} catch {
box.classList.remove('show');
label.textContent = '';
}
}
export function wireDeviceAddressPreview(fieldMap) {
const update = () => updateSolAddress(fieldMap);
$(fieldMap.devPub).addEventListener('input', update);
update();
}

View File

@ -0,0 +1,140 @@
import { readShineUserPda, updateServerOnSolana } from '../../js/services/shine-user-pda-service.js';
import {
$,
buildKeyBundleFromForm,
clearGenMessage,
clearStatus,
deriveKeyBundleFromPassword,
fillKeyFields,
formatBigInt,
formatTimestamp,
parseLoginList,
setGenMessage,
setStatus,
setupPasswordEye,
updateSolAddress,
validateLoginOrThrow,
wireDeviceAddressPreview,
} from './server-ui-shared.js';
const fieldMap = {
masterSecret: 'masterSecret',
rootPub: 'rootPub',
rootPriv: 'rootPriv',
bchPub: 'bchPub',
bchPriv: 'bchPriv',
devPub: 'devPub',
devPriv: 'devPriv',
solBox: 'solBox',
solAdr: 'solAdr',
};
let currentPda = null;
setupPasswordEye($('btnEye'), $('password'));
wireDeviceAddressPreview(fieldMap);
$('btnGen').addEventListener('click', async () => {
clearGenMessage($('genMsg'));
clearStatus($('status'));
$('btnGen').disabled = true;
try {
const login = validateLoginOrThrow($('login').value);
const password = $('password').value;
const { masterSecret32, keyBundle } = await deriveKeyBundleFromPassword({ login, password });
fillKeyFields(fieldMap, keyBundle, masterSecret32);
updateSolAddress(fieldMap);
setGenMessage($('genMsg'), 'Ключи и master secret сгенерированы из логина и пароля.', 'ok');
} catch (error) {
setGenMessage($('genMsg'), error?.message || String(error), 'err');
} finally {
$('btnGen').disabled = false;
}
});
$('btnLoad').addEventListener('click', async () => {
clearStatus($('status'));
clearGenMessage($('genMsg'));
$('btnLoad').disabled = true;
currentPda = null;
$('pdaInfo').style.display = 'none';
$('updateForm').style.display = 'none';
try {
const login = validateLoginOrThrow($('login').value);
const endpoint = String($('endpoint').value || '').trim();
if (!endpoint) throw new Error('Укажите Solana endpoint');
setStatus($('status'), 'Загрузка PDA из Solana...', 'info');
const parsed = await readShineUserPda({ login, solanaEndpoint: endpoint });
if (!parsed.isServer) throw new Error('Эта PDA не является серверной');
currentPda = parsed;
$('iAddr').textContent = parsed.pdaAddress;
$('iVer').textContent = `#${parsed.recordNumber}`;
$('iCreated').textContent = formatTimestamp(parsed.createdAtMs);
$('iUpdated').textContent = formatTimestamp(parsed.updatedAtMs);
$('iSrvAddr').textContent = parsed.serverAddress || '—';
$('iSync').textContent = parsed.syncServers.length ? parsed.syncServers.join(', ') : '—';
$('iBch').textContent = parsed.blockchain.blockchainName;
$('iLimit').textContent = formatBigInt(parsed.blockchain.paidLimitBytes);
$('serverAddress').value = parsed.serverAddress || '';
$('syncServers').value = parsed.syncServers.join('\n');
$('pdaInfo').style.display = 'block';
$('updateForm').style.display = 'block';
setStatus($('status'), 'PDA загружена. Можно менять адрес или sync_servers.', 'success');
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnLoad').disabled = false;
}
});
$('btnUpdate').addEventListener('click', async () => {
clearStatus($('status'));
clearGenMessage($('genMsg'));
$('btnUpdate').disabled = true;
try {
if (!currentPda) throw new Error('Сначала загрузите PDA');
const endpoint = String($('endpoint').value || '').trim();
if (!endpoint) throw new Error('Укажите Solana endpoint');
const serverAddress = String($('serverAddress').value || '').trim();
if (!serverAddress) throw new Error('Укажите адрес сервера');
setStatus($('status'), 'Проверка и сборка keyBundle...', 'info');
const { keyBundle, normalized } = await buildKeyBundleFromForm(fieldMap, { requireBlockchain: false });
$('rootPub').value = normalized.rootPubB58;
$('rootPriv').value = normalized.rootPrivB58;
$('bchPub').value = normalized.bchPubB58;
$('bchPriv').value = normalized.bchPrivB58;
$('devPub').value = normalized.devPubB58;
$('devPriv').value = normalized.devPrivB58;
updateSolAddress(fieldMap);
setStatus($('status'), 'Отправка update_user_pda в Solana...', 'info');
const result = await updateServerOnSolana({
login: currentPda.login,
keyBundle,
serverAddress,
addressFormatType: currentPda.addressFormatType ?? 1,
addressFormatVersion: currentPda.addressFormatVersion ?? 0,
syncServers: parseLoginList($('syncServers').value),
solanaEndpoint: endpoint,
});
setStatus(
$('status'),
`✓ PDA обновлена!\n\nЛогин: ${currentPda.login}\nPDA: ${result.pdaAddress}\nТранзакция: ${result.signature}`,
'success',
);
currentPda = null;
$('pdaInfo').style.display = 'none';
$('updateForm').style.display = 'none';
} catch (error) {
setStatus($('status'), error?.message || String(error), 'error');
} finally {
$('btnUpdate').disabled = false;
}
});
document.body.dataset.ready = '1';

View File

@ -0,0 +1,143 @@
<!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="../server-ui.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" src="./js/update-server-pda-page.js"></script>
</body>
</html>

View File

@ -1,17 +1,22 @@
# AGENTS.md — shine-server-UI
# AGENTS.md — shine-server-UI-obsolete
## Назначение
`shine-server-UI/` — автономная веб-панель администратора для управления серверным аккаунтом SHiNE
`shine-server-UI-obsolete/` — устаревшая автономная веб-панель администратора для управления серверным аккаунтом SHiNE
в Solana (регистрация и обновление `user_pda` с флагом `is_server=true`).
Это не часть основного клиентского SPA (`shine-UI/`). Страницы — самостоятельные HTML-файлы,
открываемые напрямую в браузере. Никакого бэкенда нет.
Эта папка оставлена только как справочная копия старой реализации.
Актуальная точка входа серверного UI теперь находится в:
- `shine-UI/server-ui.html`
- `shine-UI/server-ui/`
Никакого бэкенда нет.
## Структура файлов
```
shine-server-UI/
shine-server-UI-obsolete/
index.html — главная страница с навигацией
create-server-pda.html — регистрация нового серверного аккаунта
update-server-pda.html — обновление адреса/sync_servers существующей PDA

View File

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