SHA256
Основное (наша работа в этой сессии): - Переименование «subserver» → «homeserver» по всему проекту: основной ESP32-скетч (папка shine_subserver_ui → shine_homeserver_ui, .ino, flash-скрипт, режим burn.sh homeserver-ui), скетч lvgl_nav_minimal_test (ключ homeserver.key:<имя>), spec-доки reference/*, формат PDA (терминология session_type=100 «Homeserver пользователя»), константа SESSION_TYPE_HOMESERVER в JS и Rust (значение 100 не менялось, формат не затронут), pending/future доки, AGENTS.md, DAO-док. Сохранены отдельный lvgl_subserver_touch_test и историческая пометка о рендейме в DERIVATION.md. - Новый источник истины по деривации ключей: Dev_Docs/Keys/DERIVATION.md (Argon2id-секрет из пароля, формула Ed25519(SHA-256(base64(secret)|suffix)), суффиксы root/bch/dev/homeserver.key, Solana-ключ = dev.key). Уточнены роли root (главный/master) и dev (пополняемый кошелёк) в Dev_Docs/Keys/README.md. - UI: убран легаси-путь пустого пароля (derivePasswordSeed и др.), deriveMasterSecretFromPassword бросает ошибку на пустом пароле, register-view блокирует пустой пароль; экран пополнения переведён на канонический device-адрес из preGeneratedKeyBundle (удалён расходящийся deriveWalletFromPassword). Включены также параллельные правки Solana-аудита №3 (были в рабочем дереве, переплетены в lib.rs): - shine_users: defense-in-depth «строгий список аккаунтов» (require!(it.next().is_none())) в init/update economy config и create/update user PDA, плюс описание в doc/programs/shine_users.md; - Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
190 lines
6.3 KiB
JavaScript
190 lines
6.3 KiB
JavaScript
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
|
|
import { loadEncryptedUserSecrets } from './key-vault.js';
|
|
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
|
|
|
|
const DEFAULT_SOLANA_ENDPOINT = SOLANA_ENDPOINT_DEFAULT;
|
|
const TOPUP_SITE_URL = '/devnet-topup-view';
|
|
|
|
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 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 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 = extractDeviceKey32FromStoredValue(pkcs8B64);
|
|
return solana.Keypair.fromSeed(seed32);
|
|
}
|
|
|
|
export async function createRandomSolanaWallet() {
|
|
const solana = await loadSolanaLib();
|
|
const keypair = solana.Keypair.generate();
|
|
const privateKey32Base58 = solana.bs58.encode(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 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(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)}`;
|
|
}
|
|
}
|