import { renderHeader } from '../components/header.js'; import { authService, authorizeSession, refreshSessions, setAuthError, setAuthInfo, state, } from '../state.js'; import { clearStoredMessages } from '../services/message-store.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { formatSol, getBalanceSol, getTopupSiteUrl, } from '../services/solana-wallet-service.js'; import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; import { checkLoginExistsOnSolana, formatSolanaErrorDetails, isUserAlreadyExistsSolanaError, registerUserOnSolana, } from '../services/solana-register-service.js'; import { defaultServerLogin } from '../deploy-config.js'; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; const MIN_REQUIRED_SOL = 0.01; const RECOMMENDED_TOPUP_SOL = 0.02; const AUTO_LOGIN_INITIAL_DELAY_MS = 1000; const AUTO_LOGIN_RETRY_MS = 2000; const REGISTRATION_PROGRESS_DURATION_MS = 12000; const REGISTRATION_POLL_START_DELAY_MS = 4000; const REGISTRATION_POLL_INTERVAL_MS = 2000; const REGISTRATION_CONFIRM_TIMEOUT_MS = 25000; const REGISTRATION_SUCCESS_SETTLE_DELAY_MS = 2000; const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => ''); function getExplorerClusterName(endpoint) { const source = String(endpoint || '').trim().toLowerCase(); if (!source) return 'mainnet-beta'; if (source.includes('devnet')) return 'devnet'; if (source.includes('testnet')) return 'testnet'; return 'mainnet-beta'; } function isTestRegistrationContour() { const solanaCluster = getExplorerClusterName(state.entrySettings.solanaServer); if (solanaCluster !== 'mainnet-beta') return true; const shineServer = String(state.entrySettings.shineServer || '').trim().toLowerCase(); const shineServerLogin = String(state.entrySettings.shineServerLogin || '').trim().toLowerCase(); return ( shineServer.includes('t1.shineup.me') || shineServer.includes('t2.shineup.me') || shineServer.includes('t3.shineup.me') || shineServer.includes('t4.shineup.me') || ['t1', 't2', 't3', 't4'].includes(shineServerLogin) ); } function makeSolanaExplorerTxUrl(signature, endpoint) { const cleanSignature = String(signature || '').trim(); if (!cleanSignature) return ''; const cluster = getExplorerClusterName(endpoint); const url = new URL(`https://explorer.solana.com/tx/${encodeURIComponent(cleanSignature)}`); if (cluster !== 'mainnet-beta') { url.searchParams.set('cluster', cluster); } return url.toString(); } function parseBalanceSol(value) { const parsed = Number.parseFloat(String(value || '').replace(',', '.')); return Number.isFinite(parsed) ? parsed : 0; } function getCryptoRuntimeState() { const hasCrypto = Boolean(globalThis.crypto); const hasGetRandomValues = Boolean(globalThis.crypto && typeof globalThis.crypto.getRandomValues === 'function'); const hasSubtle = Boolean(globalThis.crypto && (globalThis.crypto.subtle || globalThis.crypto.webkitSubtle)); const secureContext = window.isSecureContext === true; return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext }; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } async function completeRegistrationLogin({ navigate, keyBundle }) { await authService.reconnect(state.entrySettings.shineServer); const result = await authService.createSessionForExistingUser( state.registrationDraft.login, state.registrationDraft.password, ); state.keyStorage.saveRoot = Boolean(state.keyStorage.saveRoot); state.keyStorage.saveBlockchain = Boolean(state.keyStorage.saveBlockchain); await authService.persistSelectedKeys( result.login, result.storagePwd, keyBundle, { saveRoot: state.keyStorage.saveRoot, saveBlockchain: state.keyStorage.saveBlockchain, }, ); await authService.persistSessionMaterial(result.login, result.sessionMaterial); await clearStoredMessages().catch(() => {}); const resumed = await authService.resumeSession(result.login, result.sessionId); const resumedLogin = resumed.login || result.login; const resumedSessionId = resumed.sessionId || result.sessionId; const resumedStoragePwd = resumed.storagePwd || result.storagePwd; authorizeSession({ login: resumedLogin, sessionId: resumedSessionId, storagePwd: resumedStoragePwd, }); state.loginDraft.login = resumedLogin; state.loginDraft.password = ''; state.loginDraft.passwordMode = 'single'; state.loginDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice(); state.registrationDraft.flowType = ''; state.registrationDraft.password = ''; state.registrationDraft.passwordMode = 'single'; state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice(); state.registrationDraft.passwordWordsLinked = false; state.registrationDraft.usePromoCode = false; state.registrationDraft.promoCode = ''; state.registrationDraft.storagePwd = ''; state.registrationDraft.sessionId = ''; state.registrationDraft.pendingKeyBundle = null; state.registrationDraft.pendingSessionMaterial = null; state.registrationDraft.preGeneratedKeyBundle = null; state.registrationPayment.walletAddress = ''; state.registrationPayment.balanceSOL = '0.0000'; await refreshSessions(); setAuthInfo(`Регистрация завершена. Вы автоматически вошли как @${resumedLogin}.`); const nextHash = String(state.authReturnHash || '').trim(); state.authReturnHash = ''; if (nextHash.startsWith('/')) { navigate(nextHash.slice(1)); } else { navigate('profile-view'); } } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; const card = document.createElement('div'); card.className = 'card stack'; const status = document.createElement('p'); status.className = 'status-line is-unavailable'; status.style.display = 'none'; const walletValue = document.createElement('input'); walletValue.className = 'input'; walletValue.type = 'text'; walletValue.value = state.registrationPayment.walletAddress || ''; walletValue.readOnly = true; walletValue.style.width = '100%'; const walletRow = document.createElement('div'); walletRow.className = 'stack'; const copyButton = document.createElement('button'); copyButton.className = 'ghost-btn'; copyButton.type = 'button'; copyButton.textContent = 'Скопировать номер'; copyButton.style.width = '100%'; copyButton.addEventListener('click', async () => { try { await navigator.clipboard.writeText(walletValue.value); copyButton.textContent = 'Скопировано'; window.setTimeout(() => { copyButton.textContent = 'Скопировать номер'; }, 1500); } catch { status.className = 'status-line is-unavailable'; status.textContent = 'Не удалось скопировать номер кошелька.'; status.style.display = ''; } }); walletRow.append(walletValue, copyButton); const balanceRow = document.createElement('div'); balanceRow.className = 'row wrap-row'; const balanceValue = document.createElement('strong'); balanceValue.textContent = `${formatSol(parseBalanceSol(state.registrationPayment.balanceSOL), 6)} SOL`; const refreshButton = document.createElement('button'); refreshButton.className = 'square-btn'; refreshButton.type = 'button'; refreshButton.textContent = '↻'; refreshButton.title = 'Обновить'; const refreshBalance = async ({ showError = true, addressOverride = '' } = {}) => { const address = String(addressOverride || walletValue.value || '').trim(); if (!address) return null; refreshButton.disabled = true; try { const balance = await getBalanceSol({ endpoint: state.entrySettings.solanaServer, address, }); state.registrationPayment.balanceSOL = String(balance.sol); balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`; return Number(balance.sol) || 0; } catch (error) { if (showError) { status.className = 'status-line is-unavailable'; status.textContent = `Не удалось обновить баланс: ${error?.message || 'unknown'}`; status.style.display = ''; } return null; } finally { refreshButton.disabled = false; } }; const deriveUserWalletAddress = async () => { const keyBundle = state.registrationDraft.preGeneratedKeyBundle; if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.'); const { publicKeyB64 } = keyBundle.clientPair; const raw = atob(publicKeyB64); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); const { PublicKey } = await loadSolanaWeb3(); const address = new PublicKey(bytes).toBase58(); state.registrationPayment.walletAddress = address; walletValue.value = address; return address; }; refreshButton.addEventListener('click', () => { void refreshBalance(); }); balanceRow.append(balanceValue, refreshButton); const isTestContour = isTestRegistrationContour(); const topupButton = document.createElement('button'); topupButton.className = 'ghost-btn'; topupButton.type = 'button'; topupButton.textContent = 'Пополнить кошелёк'; topupButton.addEventListener('click', async () => { try { const walletAddress = await deriveUserWalletAddress(); window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer'); } catch (error) { status.className = 'status-line is-unavailable'; status.textContent = `Не удалось подготовить кошелёк: ${error?.message || 'unknown'}`; status.style.display = ''; } }); const showKeysButton = document.createElement('button'); showKeysButton.className = 'ghost-btn'; showKeysButton.type = 'button'; showKeysButton.textContent = 'Показать сгенерированные ключи'; showKeysButton.addEventListener('click', () => navigate('registration-draft-keys-view')); const submitButton = document.createElement('button'); submitButton.className = 'primary-btn'; submitButton.type = 'button'; submitButton.textContent = 'Зарегистрироваться'; submitButton.addEventListener('click', async () => { status.style.display = 'none'; const cryptoState = getCryptoRuntimeState(); if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) { status.className = 'status-line is-unavailable'; status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.'; status.style.display = ''; return; } try { submitButton.disabled = true; submitButton.textContent = 'Регистрация...'; const walletAddress = await deriveUserWalletAddress(); const currentBalance = await refreshBalance({ showError: true, addressOverride: walletAddress }); if (currentBalance == null) return; if (currentBalance < MIN_REQUIRED_SOL) { status.className = 'status-line is-unavailable'; status.textContent = isTestContour ? `Для регистрации нужно минимум ${MIN_REQUIRED_SOL} SOL. Рекомендуем пополнить кошелёк примерно на ${RECOMMENDED_TOPUP_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL.` : `Для регистрации пополните этот кошелёк соланами примерно на ${RECOMMENDED_TOPUP_SOL} SOL. Сейчас на кошельке ${formatSol(currentBalance, 6)} SOL. Минимум для продолжения: ${MIN_REQUIRED_SOL} SOL.`; status.style.display = ''; if (isTestContour) { const openTopup = window.confirm('Открыть страницу пополнения с вашим кошельком?'); if (openTopup) { window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer'); } } return; } const keyBundle = state.registrationDraft.preGeneratedKeyBundle; if (!keyBundle) throw new Error('Ключи не найдены. Вернитесь на предыдущий шаг.'); // Регистрация на Solana (смарт контракт) submitButton.textContent = 'Регистрация в Solana...'; let registrationTxId = ''; try { const registrationResult = await registerUserOnSolana({ login: state.registrationDraft.login, keyBundle, solanaEndpoint: state.entrySettings.solanaServer, accessServers: [state.entrySettings.shineServerLogin || defaultServerLogin], promoCode: '', }); registrationTxId = String(registrationResult?.signature || '').trim(); } catch (solanaError) { const solanaMsg = formatSolanaErrorDetails(solanaError); // Пользователь уже зарегистрирован в Solana — продолжаем if (!solanaMsg.includes('already') && !isUserAlreadyExistsSolanaError(solanaError)) { throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`); } } renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId }); } catch (error) { const message = toUserMessage(error, 'Не удалось завершить регистрацию.'); setAuthError(message); status.className = 'status-line is-unavailable'; status.textContent = message; status.style.display = ''; } finally { submitButton.disabled = false; submitButton.textContent = 'Зарегистрироваться'; } }); card.innerHTML = `

Для регистрации пополните этот кошелёк соланами примерно на ${RECOMMENDED_TOPUP_SOL} SOL.

Баланс (Solana)
`; card.children[1].append(walletRow); card.children[2].append(balanceRow); if (isTestContour) { card.append(topupButton); } card.append(showKeysButton, submitButton, status); screen.append( renderHeader({ title: 'Оплата регистрации', leftAction: { label: '←', onClick: () => navigate('register-view') }, }), card, ); (async () => { try { const walletAddress = await deriveUserWalletAddress(); await refreshBalance({ addressOverride: walletAddress }); } catch (error) { status.className = 'status-line is-unavailable'; status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`; status.style.display = ''; } })(); return screen; } function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId = '' }) { const screen = document.querySelector('section.stack'); if (!screen) return; const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn'); const card = screen.querySelector('.card.stack'); if (!card) return; card.classList.add('registration-finish-card'); let progressTimerId = null; let pollStartTimerId = null; let pollTimerId = null; let successTimerId = null; let confirmationInFlight = false; let stageClosed = false; let successPending = false; let timeoutShown = false; const stageStartedAt = Date.now(); const txExplorerUrl = makeSolanaExplorerTxUrl(registrationTxId, state.entrySettings.solanaServer); const title = document.createElement('h2'); title.className = 'registration-finish-title'; title.textContent = 'Идёт регистрация в блокчейне...'; const hint = document.createElement('p'); hint.className = 'auth-copy registration-finish-text'; hint.textContent = 'Ждём, пока транзакция будет полностью одобрена сетью Solana.'; const txIdLine = document.createElement('p'); txIdLine.className = 'meta-muted registration-finish-tx'; if (registrationTxId && txExplorerUrl) { const txLabel = document.createElement('span'); txLabel.textContent = 'Tx ID регистрации: '; const txLink = document.createElement('a'); txLink.className = 'registration-finish-tx-link'; txLink.href = txExplorerUrl; txLink.target = '_blank'; txLink.rel = 'noopener noreferrer'; txLink.textContent = registrationTxId; txIdLine.append(txLabel, txLink); } else { txIdLine.textContent = 'Tx ID регистрации: ожидаем подтверждённую подпись'; } const progressWrap = document.createElement('div'); progressWrap.className = 'registration-progress'; const progressBar = document.createElement('div'); progressBar.className = 'registration-progress-bar'; progressWrap.append(progressBar); const progress = document.createElement('p'); progress.className = 'meta-muted registration-finish-progress'; progress.textContent = 'Подготавливаем проверку подтверждения...'; const pollStatus = document.createElement('p'); pollStatus.className = 'meta-muted registration-finish-progress'; pollStatus.textContent = 'Через несколько секунд начнём проверять подтверждение регистрации.'; const stopTimers = () => { if (progressTimerId) { window.clearInterval(progressTimerId); progressTimerId = null; } if (pollStartTimerId) { window.clearTimeout(pollStartTimerId); pollStartTimerId = null; } if (pollTimerId) { window.clearInterval(pollTimerId); pollTimerId = null; } if (successTimerId) { window.clearTimeout(successTimerId); successTimerId = null; } }; const updateVisualProgress = () => { const elapsed = Date.now() - stageStartedAt; let widthPercent = 0; if (elapsed <= REGISTRATION_PROGRESS_DURATION_MS) { const phaseRatio = clamp(elapsed / REGISTRATION_PROGRESS_DURATION_MS, 0, 1); widthPercent = 100 * (1 - ((1 - phaseRatio) ** 1.85)); } else { const extraRatio = 1 - Math.exp(-(elapsed - REGISTRATION_PROGRESS_DURATION_MS) / 9000); widthPercent = 88 + (10 * clamp(extraRatio, 0, 1)); } progressBar.style.width = `${clamp(widthPercent, 0, 98)}%`; if (elapsed < REGISTRATION_POLL_START_DELAY_MS) { progress.textContent = 'Отправили регистрацию в сеть. Даём транзакции несколько секунд на обработку.'; return; } if (elapsed < REGISTRATION_CONFIRM_TIMEOUT_MS) { progress.textContent = 'Идёт ожидание подтверждения Solana. Индикатор может замедлиться, это нормально.'; return; } progress.textContent = 'Подтверждение затянулось. Продолжаем автоматическую проверку.'; }; const finalizeSuccess = () => { if (stageClosed || successPending) return; successPending = true; stopTimers(); progress.textContent = 'Подтверждение получено. Завершаем регистрацию...'; pollStatus.textContent = 'Запись уже найдена в Solana, готовим финальный экран.'; status.style.display = 'none'; progressBar.style.transition = `width ${REGISTRATION_SUCCESS_SETTLE_DELAY_MS}ms ease-out`; progressBar.style.width = '100%'; successTimerId = window.setTimeout(() => { if (stageClosed) return; stageClosed = true; renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId }); }, REGISTRATION_SUCCESS_SETTLE_DELAY_MS); }; const showTimeoutState = () => { if (timeoutShown || stageClosed) return; timeoutShown = true; status.className = 'status-line is-unavailable'; status.textContent = 'Подтверждение не пришло вовремя. Возможно, регистрация уже прошла, а возможно ещё нет. Проверьте Tx ID ниже: мы продолжим автоматическую проверку.'; status.style.display = ''; pollStatus.textContent = 'Пока подтверждения нет. Подождите ещё немного, мы продолжаем проверять регистрацию.'; }; const tryCheckRegistration = async () => { if (stageClosed || confirmationInFlight) return; confirmationInFlight = true; progress.textContent = 'Проверяем подтверждение регистрации в Solana...'; status.style.display = timeoutShown ? '' : 'none'; try { const result = await checkLoginExistsOnSolana({ login: state.registrationDraft.login, solanaEndpoint: state.entrySettings.solanaServer, }); if (result?.exists) { finalizeSuccess(); return; } pollStatus.textContent = 'Пока ещё не прошла, подождите ещё немного...'; if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) { showTimeoutState(); } } catch (error) { console.warn('Registration confirmation check failed', toUserMessage(error, 'registration-confirmation')); pollStatus.textContent = 'Проверяем регистрацию повторно...'; if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) { showTimeoutState(); } } finally { confirmationInFlight = false; } }; if (headerBackButton) { const replacement = headerBackButton.cloneNode(true); replacement.addEventListener('click', () => { stageClosed = true; stopTimers(); navigate('start-view'); }); headerBackButton.replaceWith(replacement); } card.innerHTML = ''; status.style.display = 'none'; card.append(title, hint, txIdLine, progressWrap, progress, pollStatus, status); updateVisualProgress(); progressTimerId = window.setInterval(() => { if (stageClosed) return; updateVisualProgress(); if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) { showTimeoutState(); } }, 160); pollStartTimerId = window.setTimeout(() => { if (stageClosed) return; void tryCheckRegistration(); pollTimerId = window.setInterval(() => { void tryCheckRegistration(); }, REGISTRATION_POLL_INTERVAL_MS); }, REGISTRATION_POLL_START_DELAY_MS); } function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) { const screen = document.querySelector('section.stack'); if (!screen) return; const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn'); const card = screen.querySelector('.card.stack'); if (!card) return; card.classList.add('registration-finish-card'); let initialDelayId = null; let retryTimerId = null; let loginInFlight = false; let loginCompleted = false; const txExplorerUrl = makeSolanaExplorerTxUrl(registrationTxId, state.entrySettings.solanaServer); const title = document.createElement('h2'); title.className = 'registration-finish-title'; title.textContent = 'Поздравляем, регистрация завершена'; const hint = document.createElement('p'); hint.className = 'auth-copy registration-finish-text'; hint.textContent = 'Регистрация подтверждена в блокчейне Solana. Сейчас выполним автоматический вход в аккаунт.'; const txIdLine = document.createElement('p'); txIdLine.className = 'meta-muted registration-finish-tx'; if (registrationTxId && txExplorerUrl) { const txLabel = document.createElement('span'); txLabel.textContent = 'Tx ID регистрации: '; const txLink = document.createElement('a'); txLink.className = 'registration-finish-tx-link'; txLink.href = txExplorerUrl; txLink.target = '_blank'; txLink.rel = 'noopener noreferrer'; txLink.textContent = registrationTxId; txIdLine.append(txLabel, txLink); } else { txIdLine.textContent = 'Tx ID регистрации: ожидаем подтверждённую подпись'; } const progress = document.createElement('p'); progress.className = 'meta-muted registration-finish-progress'; progress.textContent = 'Подготавливаем проверку регистрации...'; const stopAutoLogin = () => { if (initialDelayId) { window.clearTimeout(initialDelayId); initialDelayId = null; } if (retryTimerId) { window.clearInterval(retryTimerId); retryTimerId = null; } }; const tryAutoLogin = async () => { if (loginCompleted || loginInFlight) return; loginInFlight = true; progress.textContent = 'Проверяем, прошла ли регистрация...'; status.style.display = 'none'; try { await completeRegistrationLogin({ navigate, keyBundle }); loginCompleted = true; stopAutoLogin(); } catch (error) { if (!loginCompleted) { progress.textContent = 'Пока ещё не прошла, подождите ещё секундочку...'; console.warn('Auto login after registration is not ready yet', toUserMessage(error, 'auto-login-wait')); } } finally { loginInFlight = false; } }; if (headerBackButton) { const replacement = headerBackButton.cloneNode(true); replacement.addEventListener('click', () => { loginCompleted = true; stopAutoLogin(); navigate('start-view'); }); headerBackButton.replaceWith(replacement); } card.innerHTML = ''; card.append(title, hint, txIdLine, progress, status); initialDelayId = window.setTimeout(() => { if (loginCompleted) return; void tryAutoLogin(); retryTimerId = window.setInterval(() => { void tryAutoLogin(); }, AUTO_LOGIN_RETRY_MS); }, AUTO_LOGIN_INITIAL_DELAY_MS); }