import { base58ToBytes, base64ToBytes, bytesToBase58, bytesToBase64, deriveEd25519FromMasterSecret, deriveMasterSecretFromPassword, publicKeyB64FromPkcs8Ed25519, } from '../../js/services/crypto-utils.js'; import { formatSol, getBalanceSol } from '../../js/services/solana-wallet-service.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; let recovery = null; const masterSecretValue = fieldMap.masterSecret ? String($(fieldMap.masterSecret).value || '').trim() : ''; if (masterSecretValue) { const masterSecret32 = ensure32Bytes(base58ToBytes(masterSecretValue)); const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'); recovery = { publicKeyB64: recoveryPair.publicKeyB64, privatePkcs8B64: recoveryPair.privatePkcs8B64, publicKeyB58: bytesToBase58(base64ToBytes(recoveryPair.publicKeyB64)), privateSeedB58: bytesToBase58(base64ToBytes(recoveryPair.privatePkcs8B64).slice(-32)), }; } 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: { recoveryPair: recovery ? { publicKeyB64: recovery.publicKeyB64, privatePkcs8B64: recovery.privatePkcs8B64 } : null, rootPair: { publicKeyB64: root.publicKeyB64, privatePkcs8B64: root.privatePkcs8B64 }, blockchainPair: blockchain ? { publicKeyB64: blockchain.publicKeyB64, privatePkcs8B64: blockchain.privatePkcs8B64 } : null, clientPair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 }, }, normalized: { recoveryPubB58: recovery?.publicKeyB58 || '', recoveryPrivB58: recovery?.privateSeedB58 || '', 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 [recoveryPair, rootPair, blockchainPair, clientPair] = await Promise.all([ deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'), deriveEd25519FromMasterSecret(masterSecret32, 'root.key'), deriveEd25519FromMasterSecret(masterSecret32, 'blockchain.key'), deriveEd25519FromMasterSecret(masterSecret32, 'client.key'), ]); return { masterSecret32, keyBundle: { recoveryPair, rootPair, blockchainPair, clientPair, }, }; } export function fillKeyFields(fieldMap, keyBundle, masterSecret32) { if (masterSecret32) { $(fieldMap.masterSecret).value = bytesToBase58(masterSecret32); } if (fieldMap.recoveryPub && fieldMap.recoveryPriv && keyBundle.recoveryPair) { $(fieldMap.recoveryPub).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64)); $(fieldMap.recoveryPriv).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.privatePkcs8B64).slice(-32)); } $(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.clientPair.publicKeyB64)); $(fieldMap.devPriv).value = bytesToBase58(base64ToBytes(keyBundle.clientPair.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 setText(idOrNode, value) { const node = typeof idOrNode === 'string' ? $(idOrNode) : idOrNode; if (node) node.textContent = String(value || ''); } export function wireDeviceAddressPreview(fieldMap) { const update = () => updateSolAddress(fieldMap); $(fieldMap.devPub).addEventListener('input', update); update(); } export function publicKeyBytesToBase58(value) { return bytesToBase58(value instanceof Uint8Array ? value : new Uint8Array(value || [])); } export function compareExpectedPublicKeys(expected, actual) { const exp = String(expected || '').trim(); const act = String(actual || '').trim(); return { matches: Boolean(exp) && Boolean(act) && exp === act, expected: exp, actual: act, }; } export function summarizeKeyComparison(resultMap) { const labels = { root: 'root', blockchain: 'blockchain', device: 'device', }; const mismatches = Object.entries(resultMap) .filter(([, result]) => !result.matches) .map(([key]) => labels[key] || key); return { allMatch: mismatches.length === 0, mismatches, }; } export async function refreshDeviceBalance({ endpoint, deviceAddress, targetNode }) { const cleanEndpoint = String(endpoint || '').trim(); const cleanAddress = String(deviceAddress || '').trim(); if (!cleanEndpoint) throw new Error('Укажите Solana endpoint'); if (!cleanAddress) throw new Error('Сначала укажите device-адрес'); const balance = await getBalanceSol({ endpoint: cleanEndpoint, address: cleanAddress }); setText(targetNode, `Баланс device: ${formatSol(balance.sol, 6)} SOL (${balance.lamports} lamports)`); return balance; } export function buildDevnetTopupUrl(walletAddress) { const cleanWallet = String(walletAddress || '').trim(); const url = new URL('../devnet-topup-view', window.location.href); if (cleanWallet) { url.searchParams.set('wallet', cleanWallet); } return url.toString(); } export function openDevnetTopup(walletAddress) { const cleanWallet = String(walletAddress || '').trim(); if (!cleanWallet) { throw new Error('Сначала укажите device-адрес'); } window.open(buildDevnetTopupUrl(cleanWallet), '_blank', 'noopener'); }