SHiNE-server/shine-UI/js/pages/topup-view.js
AidarKC 42dcf6970d homeserver: рендейм subserver→homeserver, документ деривации ключей, запрет пустого пароля
Основное (наша работа в этой сессии):
- Переименование «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>
2026-06-12 21:16:12 +04:00

151 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import {
formatSol,
getBalanceSol,
getTopupSiteUrl,
requestAirdropSol,
} from '../services/solana-wallet-service.js';
export const pageMeta = { id: 'topup-view', title: 'Пополнение счета', showAppChrome: false };
// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей.
// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес
// напрямую из пароля, иначе он расходится с device-ключом регистрации.
async function deviceWalletAddressFromBundle() {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle || !keyBundle.devicePair) {
throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.');
}
const raw = atob(keyBundle.devicePair.publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await import('https://esm.sh/@solana/web3.js@1.98.4');
return new PublicKey(bytes).toBase58();
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const walletValue = document.createElement('input');
walletValue.className = 'input';
walletValue.type = 'text';
walletValue.value = state.registrationPayment.walletAddress || '';
walletValue.readOnly = true;
walletValue.style.fontSize = '13px';
const status = document.createElement('p');
status.className = 'meta-muted';
status.textContent = 'Проверяем кошелек...';
const copyButton = document.createElement('button');
copyButton.className = 'ghost-btn';
copyButton.type = 'button';
copyButton.textContent = 'Скопировать';
copyButton.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(walletValue.value);
copyButton.textContent = 'Скопировано';
window.setTimeout(() => {
copyButton.textContent = 'Скопировать';
}, 1500);
} catch {
window.alert('Не удалось скопировать номер кошелька.');
}
});
const walletRow = document.createElement('div');
walletRow.className = 'inline-input-row';
walletRow.append(walletValue, copyButton);
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<p class="auth-copy">Кнопка «Пополнить» в кошельке будет переводить на отдельный сайт. Пока доступно тестовое пополнение.</p>
<div class="stack" style="gap:6px;">
<p class="meta-muted">1. Вы можете открыть промо-сайт пополнения с подставленным кошельком.</p>
<p class="meta-muted">2. Либо нажать «Тестовое пополнение» и получить 1 SOL через DevNet airdrop.</p>
</div>
<a class="link-card" id="topup-site-link" href="${getTopupSiteUrl(state.registrationPayment.walletAddress || '')}" target="_blank" rel="noreferrer">Открыть сайт пополнения</a>
<div class="card stack" style="padding:12px; max-width:320px;">
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (device key = Solana wallet)</div>
</div>
`;
card.children[3].append(walletRow);
const testButton = document.createElement('button');
testButton.className = 'ghost-btn';
testButton.type = 'button';
testButton.textContent = 'Тестовое пополнение (1 SOL)';
testButton.addEventListener('click', async () => {
const address = String(walletValue.value || '').trim();
if (!address) {
window.alert('Адрес кошелька не найден.');
return;
}
testButton.disabled = true;
try {
const drop = await requestAirdropSol({
endpoint: state.entrySettings.solanaServer,
address,
amountSol: 1,
});
const bal = await getBalanceSol({
endpoint: state.entrySettings.solanaServer,
address,
});
state.registrationPayment.balanceSOL = String(bal.sol);
status.textContent = `Тестовое пополнение выполнено. Новый баланс: ${formatSol(bal.sol, 6)} SOL. Signature: ${drop.signature}`;
} catch (error) {
status.textContent = `Ошибка тестового пополнения: ${error?.message || 'unknown'}`;
} finally {
testButton.disabled = false;
}
});
const backButton = document.createElement('button');
backButton.className = 'primary-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
backButton.addEventListener('click', () => navigate('registration-payment-view'));
const actions = document.createElement('div');
actions.className = 'auth-footer-actions';
actions.append(testButton, backButton);
(async () => {
try {
if (!walletValue.value) {
const address = await deviceWalletAddressFromBundle();
state.registrationPayment.walletAddress = address;
walletValue.value = address;
}
const topupSiteLink = card.querySelector('#topup-site-link');
if (topupSiteLink instanceof HTMLAnchorElement) {
topupSiteLink.href = getTopupSiteUrl(walletValue.value);
}
const balance = await getBalanceSol({
endpoint: state.entrySettings.solanaServer,
address: walletValue.value,
});
state.registrationPayment.balanceSOL = String(balance.sol);
status.textContent = `Текущий баланс: ${formatSol(balance.sol, 6)} SOL`;
} catch (error) {
status.textContent = `Не удалось получить баланс: ${error?.message || 'unknown'}`;
}
})();
screen.append(
renderHeader({
title: 'Пополнение счета',
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
}),
card,
status,
actions,
);
return screen;
}