feat(ui): кошелек на device.key и проверки серверов (тоже пока не проверено)

This commit is contained in:
AidarKC
2026-04-21 03:12:22 +03:00
parent 8be56192cb
commit e63c53a855
9 changed files with 588 additions and 79 deletions
+1 -1
View File
@@ -645,7 +645,7 @@ export class AuthService {
const addResp = await this.ws.request('AddUser', {
login: cleanLogin,
blockchainName: `${cleanLogin}-${BCH_SUFFIX}`,
solanaKey: keyBundle.rootPair.publicKeyB64,
solanaKey: keyBundle.devicePair.publicKeyB64,
blockchainKey: keyBundle.blockchainPair.publicKeyB64,
deviceKey: keyBundle.devicePair.publicKeyB64,
bchLimit: 1000000,
@@ -0,0 +1,118 @@
function normalizeUrl(value) {
return String(value || '').trim();
}
function normalizeShineWsUrl(rawUrl) {
const value = normalizeUrl(rawUrl);
if (!value) return '';
if (value.startsWith('ws://') || value.startsWith('wss://')) {
try {
const parsed = new URL(value);
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return value;
}
}
if (value.startsWith('http://') || value.startsWith('https://')) {
try {
const parsed = new URL(value);
parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
if (!parsed.pathname || parsed.pathname === '/') parsed.pathname = '/ws';
return parsed.toString();
} catch {
return `${value.replace(/^http/, 'ws').replace(/\/$/, '')}/ws`;
}
}
return value;
}
async function checkSolanaRpc(url) {
const endpoint = normalizeUrl(url);
if (!endpoint) return false;
try {
const resp = await fetch(endpoint, {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getVersion',
}),
});
if (!resp.ok) return false;
const json = await resp.json();
return Boolean(json?.result?.['solana-core'] || json?.result);
} catch {
return false;
}
}
async function checkArweave(url) {
const base = normalizeUrl(url).replace(/\/+$/, '');
if (!base) return false;
try {
const resp = await fetch(`${base}/info`, { method: 'GET', cache: 'no-store' });
if (!resp.ok) return false;
const json = await resp.json();
return Boolean(json?.network);
} catch {
return false;
}
}
function checkShineWs(url, timeoutMs = 7000) {
const wsUrl = normalizeShineWsUrl(url);
if (!wsUrl) return Promise.resolve(false);
return new Promise((resolve) => {
let done = false;
const finish = (ok) => {
if (done) return;
done = true;
window.clearTimeout(timer);
try { ws.close(); } catch {}
resolve(Boolean(ok));
};
const timer = window.setTimeout(() => finish(false), timeoutMs);
let ws;
try {
ws = new WebSocket(wsUrl);
} catch {
finish(false);
return;
}
ws.addEventListener('open', () => {
try {
ws.send(JSON.stringify({
op: 'Ping',
requestId: `check-${Date.now()}`,
payload: { ts: Date.now() },
}));
} catch {
finish(false);
}
}, { once: true });
ws.addEventListener('message', () => finish(true), { once: true });
ws.addEventListener('error', () => finish(false), { once: true });
ws.addEventListener('close', () => finish(false), { once: true });
});
}
export async function checkServerAvailabilityByKey(key, url) {
if (key === 'solanaServer') {
return (await checkSolanaRpc(url)) ? 'available' : 'unavailable';
}
if (key === 'shineServer') {
return (await checkShineWs(url)) ? 'available' : 'unavailable';
}
if (key === 'arweaveServer') {
return (await checkArweave(url)) ? 'available' : 'unavailable';
}
return 'unavailable';
}
@@ -0,0 +1,126 @@
import { base64ToBytes, deriveEd25519FromPassword } from './crypto-utils.js';
import { loadEncryptedUserSecrets } from './key-vault.js';
const DEFAULT_SOLANA_ENDPOINT = 'https://api.devnet.solana.com';
const TOPUP_SITE_URL = 'https://www.moonpay.com/buy/sol';
let solanaLibPromise = null;
function normalizeEndpoint(url) {
const raw = String(url || '').trim();
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
return raw;
}
async function loadSolanaLib() {
if (!solanaLibPromise) {
solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
}
return solanaLibPromise;
}
function extractSeed32FromPkcs8B64(pkcs8B64) {
const bytes = base64ToBytes(String(pkcs8B64 || '').trim());
if (bytes.length < 32) throw new Error('Некорректный PKCS8 ключ device.key');
return bytes.slice(bytes.length - 32);
}
async function keypairFromPkcs8(pkcs8B64) {
const solana = await loadSolanaLib();
const seed32 = extractSeed32FromPkcs8B64(pkcs8B64);
return solana.Keypair.fromSeed(seed32);
}
export async function deriveWalletFromPassword(password) {
const keyBundle = await deriveEd25519FromPassword(String(password ?? ''), 'dev.key');
const keypair = await keypairFromPkcs8(keyBundle.privatePkcs8B64);
return {
address: keypair.publicKey.toBase58(),
keypair,
devicePublicKeyB64: keyBundle.publicKeyB64,
devicePrivatePkcs8B64: keyBundle.privatePkcs8B64,
};
}
export async function getWalletFromStoredDeviceKey({ login, storagePwd }) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) {
throw new Error('Нет активной сессии для доступа к wallet.key');
}
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
const devicePrivate = String(secrets?.deviceKey || '').trim();
if (!devicePrivate) {
throw new Error('На устройстве не найден device.key (wallet.key)');
}
const keypair = await keypairFromPkcs8(devicePrivate);
return {
address: keypair.publicKey.toBase58(),
keypair,
devicePrivatePkcs8B64: devicePrivate,
};
}
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() {
return TOPUP_SITE_URL;
}