diff --git a/AGENTS.md b/AGENTS.md index 377fd50..a486665 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 деплоя. diff --git a/CLAUDE.md b/CLAUDE.md index 1cf3fd3..d3f6a87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/Dev_Docs/Pending_Features/2026-06-03_1508_перенос_server_ui_в_shine_ui.md b/Dev_Docs/Pending_Features/2026-06-03_1508_перенос_server_ui_в_shine_ui.md new file mode 100644 index 0000000..7f73e07 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-03_1508_перенос_server_ui_в_shine_ui.md @@ -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 diff --git a/SHiNE-server/AGENTS.md b/SHiNE-server/AGENTS.md index 793f54d..61ffe64 100644 --- a/SHiNE-server/AGENTS.md +++ b/SHiNE-server/AGENTS.md @@ -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-ключ не нужен). diff --git a/VERSION.properties b/VERSION.properties index ccc54f1..e21b073 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.116 -server.version=1.2.108 +client.version=1.2.117 +server.version=1.2.109 diff --git a/shine-UI/js/services/crypto-utils.js b/shine-UI/js/services/crypto-utils.js index a74f00c..f9b5ff5 100644 --- a/shine-UI/js/services/crypto-utils.js +++ b/shine-UI/js/services/crypto-utils.js @@ -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); diff --git a/shine-UI/js/services/shine-blockchain-wallet-service.js b/shine-UI/js/services/shine-blockchain-wallet-service.js index a09e27b..c425ae1 100644 --- a/shine-UI/js/services/shine-blockchain-wallet-service.js +++ b/shine-UI/js/services/shine-blockchain-wallet-service.js @@ -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'; diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js new file mode 100644 index 0000000..8f38e9b --- /dev/null +++ b/shine-UI/js/services/shine-user-pda-service.js @@ -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; +} diff --git a/shine-UI/js/services/solana-register-service.js b/shine-UI/js/services/solana-register-service.js index 93913fd..b5d77ba 100644 --- a/shine-UI/js/services/solana-register-service.js +++ b/shine-UI/js/services/solana-register-service.js @@ -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 }); } diff --git a/shine-UI/server-ui.html b/shine-UI/server-ui.html new file mode 100644 index 0000000..9b117c8 --- /dev/null +++ b/shine-UI/server-ui.html @@ -0,0 +1,58 @@ + + + + + + SHiNE Server Admin + + + +
+

SHiNE Server Admin

+

Панель управления Solana PDA для серверного аккаунта SHiNE

+ +
+

Действия

+ +
+ + + +
+
+ + + +
+
+ +
+

Как это работает

+

+ Каждый SHiNE-сервер регистрирует свой аккаунт в Solana в виде user_pda + с флагом is_server=true.

+ В PDA хранятся:
+  • адрес сервера (например, https://shineup.me/ws);
+  • список серверов-партнёров для синхронизации блокчейна и DM;
+  • криптографический корневой ключ сервера.

+ Клиенты читают PDA прямо из Solana при попытке дозвониться до пользователя или + установить WebSocket-соединение через сервер. +

+
+ +
+

Что потребуется

+

+ Для создания: полный keyBundle сервера (rootPair + devicePair + blockchainPair), + логин сервера (без точки, не более 20 символов), URL-адрес сервера, Solana-эндпоинт, + достаточный баланс SOL на device-ключе для комиссии.

+ Для обновления: только rootPair + devicePair (blockchain-ключ не нужен). +

+
+
+ + diff --git a/shine-UI/server-ui/create-server-pda.html b/shine-UI/server-ui/create-server-pda.html new file mode 100644 index 0000000..df42d52 --- /dev/null +++ b/shine-UI/server-ui/create-server-pda.html @@ -0,0 +1,127 @@ + + + + + + Регистрация сервера — SHiNE Server Admin + + + + +
+ + +

Регистрация серверного аккаунта

+

Создаёт user_pda в Solana с флагом is_server=true

+ +
+

Параметры Solana

+
+ + +
devnet: https://api.devnet.solana.com · mainnet: https://api.mainnet-beta.solana.com
+
+
+ +
+

Данные сервера

+
+ + +
Только a-z, 0-9, _ · без точки · макс. 20 символов
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Ключи сервера

+ +
+ +
+ + +
+
Нажмите «Сгенерировать» — поля ниже заполнятся из логина + пароля (Argon2id).
Или введите ключи вручную.
+
+ +
+ +
+
+ +
Секрет (master secret, base58)
+
+ +
+ +
Ключевые пары (base58)
+ +
+
Root Key — подпись PDA-записи
+
Публичный
+
Приватный
+
+
+
Blockchain Key — подпись LastBlockState
+
Публичный
+
Приватный
+
+
+
Device Key — оплата транзакции Solana
+
Публичный
+
Приватный
+
+
Положите SOL на этот адрес перед регистрацией:
+
+
Это Solana-адрес device-ключа (public key в base58 = Solana-адрес). С него оплачивается создание PDA.
+
+
+
+ +
+ +
+
+
+ + + + diff --git a/shine-UI/server-ui/js/create-server-pda-page.js b/shine-UI/server-ui/js/create-server-pda-page.js new file mode 100644 index 0000000..550c50c --- /dev/null +++ b/shine-UI/server-ui/js/create-server-pda-page.js @@ -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'; diff --git a/shine-UI/server-ui/js/server-ui-shared.js b/shine-UI/server-ui/js/server-ui-shared.js new file mode 100644 index 0000000..2ef9ee6 --- /dev/null +++ b/shine-UI/server-ui/js/server-ui-shared.js @@ -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(); +} diff --git a/shine-UI/server-ui/js/update-server-pda-page.js b/shine-UI/server-ui/js/update-server-pda-page.js new file mode 100644 index 0000000..bdc406a --- /dev/null +++ b/shine-UI/server-ui/js/update-server-pda-page.js @@ -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'; diff --git a/shine-server-UI/styles.css b/shine-UI/server-ui/styles.css similarity index 100% rename from shine-server-UI/styles.css rename to shine-UI/server-ui/styles.css diff --git a/shine-UI/server-ui/update-server-pda.html b/shine-UI/server-ui/update-server-pda.html new file mode 100644 index 0000000..93080c1 --- /dev/null +++ b/shine-UI/server-ui/update-server-pda.html @@ -0,0 +1,143 @@ + + + + + + Обновление PDA сервера — SHiNE Server Admin + + + + +
+ + +

Обновление PDA сервера

+

Меняет адрес сервера или список серверов синхронизации

+ +
+

Параметры Solana

+
+ + +
+
+ +
+

Загрузить существующую PDA

+
+ + +
+
+ +
+
+
+
PDA адрес
+
Версия
+
Создан
+
Обновлён
+
Адрес сервера
+
sync_servers
+
Blockchain
+
Paid limit
+
+
+ + + +
+
+ + + + diff --git a/shine-server-UI/AGENTS.md b/shine-server-UI-obsolete/AGENTS.md similarity index 89% rename from shine-server-UI/AGENTS.md rename to shine-server-UI-obsolete/AGENTS.md index 0aa2c74..3d3b5c7 100644 --- a/shine-server-UI/AGENTS.md +++ b/shine-server-UI-obsolete/AGENTS.md @@ -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 diff --git a/shine-server-UI/create-server-pda.html b/shine-server-UI-obsolete/create-server-pda.html similarity index 100% rename from shine-server-UI/create-server-pda.html rename to shine-server-UI-obsolete/create-server-pda.html diff --git a/shine-server-UI/index.html b/shine-server-UI-obsolete/index.html similarity index 100% rename from shine-server-UI/index.html rename to shine-server-UI-obsolete/index.html diff --git a/shine-server-UI/js/server-pda-core.js b/shine-server-UI-obsolete/js/server-pda-core.js similarity index 100% rename from shine-server-UI/js/server-pda-core.js rename to shine-server-UI-obsolete/js/server-pda-core.js diff --git a/shine-server-UI-obsolete/styles.css b/shine-server-UI-obsolete/styles.css new file mode 100644 index 0000000..5f87282 --- /dev/null +++ b/shine-server-UI-obsolete/styles.css @@ -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; +} diff --git a/shine-server-UI/update-server-pda.html b/shine-server-UI-obsolete/update-server-pda.html similarity index 100% rename from shine-server-UI/update-server-pda.html rename to shine-server-UI-obsolete/update-server-pda.html