Files
SHiNE-server/shine-UI/js/services/solana-wallet-service.js

293 lines
9.3 KiB
JavaScript

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)}`;
}
}