import { extractClientKey32FromStoredValue } from './client-key-utils.js'; import { loadEncryptedUserSecrets } from './key-vault.js'; import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js'; import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT; const TOPUP_SITE_URL = '/devnet-topup'; let solanaLibPromise = null; const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/; const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; const BASE58_MAP = (() => { const out = Object.create(null); for (let i = 0; i < BASE58_ALPHABET.length; i += 1) { out[BASE58_ALPHABET[i]] = i; } return out; })(); function encodeBase58(bytesLike) { const bytes = bytesLike instanceof Uint8Array ? bytesLike : Uint8Array.from(bytesLike || []); if (!bytes.length) return ''; const digits = [0]; for (let i = 0; i < bytes.length; i += 1) { let carry = bytes[i]; for (let j = 0; j < digits.length; j += 1) { const value = (digits[j] << 8) + carry; digits[j] = value % 58; carry = (value / 58) | 0; } while (carry > 0) { digits.push(carry % 58); carry = (carry / 58) | 0; } } let out = ''; for (let i = 0; i < bytes.length && bytes[i] === 0; i += 1) { out += BASE58_ALPHABET[0]; } for (let i = digits.length - 1; i >= 0; i -= 1) { out += BASE58_ALPHABET[digits[i]]; } return out; } function normalizeEndpoint(url) { const raw = String(url || '').trim(); if (!raw) return DEFAULT_SOLANA_ENDPOINT; return raw; } async function loadSolanaLib() { if (!solanaLibPromise) { solanaLibPromise = loadSolanaWeb3(); } return solanaLibPromise; } function decodeBase58(input) { const text = String(input || '').trim(); if (!text) return new Uint8Array(0); const bytes = [0]; for (let i = 0; i < text.length; i += 1) { const ch = text[i]; const value = BASE58_MAP[ch]; if (value == null) { throw new Error('Недопустимый символ Base58'); } let carry = value; for (let j = 0; j < bytes.length; j += 1) { const x = (bytes[j] * 58) + carry; bytes[j] = x & 0xff; carry = x >> 8; } while (carry > 0) { bytes.push(carry & 0xff); carry >>= 8; } } for (let i = 0; i < text.length && text[i] === '1'; i += 1) { bytes.push(0); } bytes.reverse(); return Uint8Array.from(bytes); } async function keypairFromPkcs8(pkcs8B64) { const solana = await loadSolanaLib(); const seed32 = extractClientKey32FromStoredValue(pkcs8B64); try { return solana.Keypair.fromSeed(seed32); } finally { seed32.fill(0); } } async function keypairFromStoredSecret(storedSecret) { const solana = await loadSolanaLib(); const seed32 = extractClientKey32FromStoredValue(storedSecret); try { return solana.Keypair.fromSeed(seed32); } finally { seed32.fill(0); } } export async function createRandomSolanaWallet() { const solana = await loadSolanaLib(); const keypair = solana.Keypair.generate(); const privateKey32Base58 = encodeBase58(keypair.secretKey.slice(0, 32)); return { address: keypair.publicKey.toBase58(), privateKey32Base58, keypair, }; } export async function createSolanaWalletFromPrivateBase58(privateKey32Base58) { const solana = await loadSolanaLib(); const clean = String(privateKey32Base58 || '').trim(); if (!clean) throw new Error('Введите приватный ключ'); if (!BASE58_RE.test(clean)) throw new Error('Разрешены только символы Base58'); const privateBytes = decodeBase58(clean); if (privateBytes.length !== 32) { throw new Error('Приватный ключ должен быть ровно 32 байта в Base58'); } const keypair = solana.Keypair.fromSeed(privateBytes); return { address: keypair.publicKey.toBase58(), privateKey32Base58: clean, keypair, }; } export async function getWalletFromStoredClientKey({ login, storagePwd }) { const cleanLogin = String(login || '').trim(); const cleanPwd = String(storagePwd || '').trim(); if (!cleanLogin || !cleanPwd) { throw new Error('Нет активной сессии для доступа к client.key'); } const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd); const clientPrivate = String(secrets?.clientKey || '').trim(); if (!clientPrivate) { throw new Error('На устройстве не найден client.key'); } const keypair = await keypairFromStoredSecret(clientPrivate); return { address: keypair.publicKey.toBase58(), keypair, clientPrivatePkcs8B64: clientPrivate, }; } export async function getWalletFromStoredRootKey({ login, storagePwd }) { const cleanLogin = String(login || '').trim(); const cleanPwd = String(storagePwd || '').trim(); if (!cleanLogin || !cleanPwd) { throw new Error('Нет активной сессии для доступа к root key'); } const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd); const rootPrivate = String(secrets?.rootKey || '').trim(); if (!rootPrivate) { throw new Error('На устройстве не найден root key'); } const keypair = await keypairFromStoredSecret(rootPrivate); return { address: keypair.publicKey.toBase58(), keypair, rootPrivatePkcs8B64: rootPrivate, }; } export async function getStoredSolanaWalletChoices({ login, storagePwd, includeRoot = true } = {}) { const cleanLogin = String(login || '').trim(); const cleanPwd = String(storagePwd || '').trim(); if (!cleanLogin || !cleanPwd) { throw new Error('Нет активной сессии для доступа к ключам'); } const choices = []; const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd }); choices.push({ id: 'client-key', keySource: 'client', label: 'client key', address: clientWallet.address, keypair: clientWallet.keypair, }); if (includeRoot) { try { const rootWallet = await getWalletFromStoredRootKey({ login: cleanLogin, storagePwd: cleanPwd }); choices.push({ id: 'root-key', keySource: 'root', label: 'root key', address: rootWallet.address, keypair: rootWallet.keypair, }); } catch { // root key на устройстве может отсутствовать, это допустимо } } return choices; } export async function encodeSolanaSecretKeyBase58(secretKey) { const bytes = secretKey instanceof Uint8Array ? secretKey : Uint8Array.from(secretKey || []); if (!bytes.length) throw new Error('Пустой Solana secret key'); return encodeBase58(bytes); } export async function getBalanceSol({ endpoint, address }) { const solana = await loadSolanaLib(); const rpc = normalizeEndpoint(endpoint); const conn = new solana.Connection(rpc, 'confirmed'); const pubkey = new solana.PublicKey(String(address || '').trim()); const lamports = await conn.getBalance(pubkey, 'confirmed'); return { endpoint: rpc, lamports, sol: lamports / solana.LAMPORTS_PER_SOL, }; } export async function requestAirdropSol({ endpoint, address, amountSol = 1 }) { const solana = await loadSolanaLib(); const rpc = normalizeEndpoint(endpoint); const conn = new solana.Connection(rpc, 'confirmed'); const pubkey = new solana.PublicKey(String(address || '').trim()); const lamports = Math.max(1, Math.floor(Number(amountSol) * solana.LAMPORTS_PER_SOL)); const signature = await conn.requestAirdrop(pubkey, lamports); await conn.confirmTransaction(signature, 'confirmed'); return { endpoint: rpc, signature, lamports }; } export async function transferSol({ endpoint, fromKeypair, toAddress, amountSol }) { const solana = await loadSolanaLib(); const rpc = normalizeEndpoint(endpoint); const cleanTo = String(toAddress || '').trim(); const amount = Number(amountSol); if (!cleanTo) throw new Error('Не указан адрес получателя'); if (!Number.isFinite(amount) || amount <= 0) throw new Error('Сумма перевода должна быть больше 0'); const conn = new solana.Connection(rpc, 'confirmed'); const lamports = Math.floor(amount * solana.LAMPORTS_PER_SOL); if (lamports <= 0) throw new Error('Сумма слишком мала'); const tx = new solana.Transaction().add( solana.SystemProgram.transfer({ fromPubkey: fromKeypair.publicKey, toPubkey: new solana.PublicKey(cleanTo), lamports, }), ); const signature = await solana.sendAndConfirmTransaction(conn, tx, [fromKeypair], { commitment: 'confirmed', }); return { endpoint: rpc, signature, lamports }; } export function formatSol(value, digits = 6) { const n = Number(value); if (!Number.isFinite(n)) return '0'; return n.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: digits, }); } export function getTopupSiteUrl(walletAddress = '') { const cleanWallet = String(walletAddress || '').trim(); if (!cleanWallet) return TOPUP_SITE_URL; try { const base = typeof window !== 'undefined' && window.location?.origin ? window.location.origin : 'http://localhost:8088'; const url = new URL(TOPUP_SITE_URL, base); url.searchParams.set('wallet', cleanWallet); return url.toString(); } catch { return `${TOPUP_SITE_URL}?wallet=${encodeURIComponent(cleanWallet)}`; } }