Регистрация: ожидание подтверждения Solana

This commit is contained in:
AidarKC
2026-07-17 18:31:05 +04:00
parent 2890cdd457
commit daa516babe
6 changed files with 251 additions and 105 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.2.327 client.version=1.2.329
server.version=1.2.299 server.version=1.2.301
+1 -1
View File
@@ -1,7 +1,7 @@
plugins { plugins {
id 'java' id 'java'
id 'application' id 'application'
id 'com.github.johnrengelman.shadow' version '8.1.1' проверь ещё id 'com.github.johnrengelman.shadow' version '8.1.1'
} }
def appVersionProps = new Properties() def appVersionProps = new Properties()
@@ -0,0 +1,20 @@
## Кратко
- Переделан финальный flow регистрации: сначала экран ожидания подтверждения Solana с прогрессом и повторными проверками, потом отдельный экран успешной регистрации с кнопкой входа в стандартный экран сохранения ключей.
## Что проверять
- После отправки регистрации открывается экран ожидания с текстом про подтверждение Solana и кликабельным `Tx ID`.
- Прогресс-бар сначала идёт быстрее, потом заметно замедляется.
- Первая проверка регистрации начинается примерно через 4 секунды, далее повторяется раз в 2 секунды.
- Пока подтверждения нет, снизу показываются понятные статусы ожидания.
- После подтверждения открывается экран «Поздравляем с регистрацией» с одной кнопкой `Войти в аккаунт`.
- Кнопка `Войти в аккаунт` открывает штатный экран сохранения ключей с тремя галочками.
- Стрелка назад в левом верхнем углу на обоих экранах возвращает в главное меню.
- После успешного сохранения ключей регистрационный черновик и адрес кошелька очищаются.
## Ожидаемый результат
- Пользователь не видит преждевременное поздравление до подтверждения регистрации в Solana.
- Финальный успех показывается только после появления подтверждения регистрации.
- Вход после регистрации идёт через тот же стандартный сценарий сохранения ключей, что и в обычном login-flow.
## Статус
- pending
@@ -162,6 +162,9 @@ export function render({ navigate }) {
state.registrationDraft.sessionId = ''; state.registrationDraft.sessionId = '';
state.registrationDraft.pendingKeyBundle = null; state.registrationDraft.pendingKeyBundle = null;
state.registrationDraft.pendingSessionMaterial = null; state.registrationDraft.pendingSessionMaterial = null;
state.registrationDraft.preGeneratedKeyBundle = null;
state.registrationPayment.walletAddress = '';
state.registrationPayment.balanceSOL = '0.0000';
await refreshSessions(); await refreshSessions();
setAuthInfo(isLoginFlow setAuthInfo(isLoginFlow
+220 -101
View File
@@ -1,8 +1,6 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { import {
authService, authService,
authorizeSession,
refreshSessions,
setAuthError, setAuthError,
setAuthInfo, setAuthInfo,
state, state,
@@ -16,6 +14,7 @@ import {
} from '../services/solana-wallet-service.js'; } from '../services/solana-wallet-service.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import { import {
checkLoginExistsOnSolana,
formatSolanaErrorDetails, formatSolanaErrorDetails,
isUserAlreadyExistsSolanaError, isUserAlreadyExistsSolanaError,
registerUserOnSolana, registerUserOnSolana,
@@ -24,9 +23,11 @@ import { defaultServerLogin } from '../deploy-config.js';
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false }; export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
const MIN_REQUIRED_SOL = 0.01; const MIN_REQUIRED_SOL = 0.01;
const AUTO_LOGIN_INITIAL_DELAY_MS = 10000; const REGISTRATION_PROGRESS_DURATION_MS = 12000;
const AUTO_LOGIN_RETRY_MS = 2000; const REGISTRATION_POLL_START_DELAY_MS = 4000;
const EMPTY_PASSWORD_WORDS = Array.from({ length: 12 }, () => ''); const REGISTRATION_POLL_INTERVAL_MS = 2000;
const REGISTRATION_CONFIRM_TIMEOUT_MS = 25000;
const REGISTRATION_SUCCESS_SETTLE_DELAY_MS = 2000;
function getExplorerClusterName(endpoint) { function getExplorerClusterName(endpoint) {
const source = String(endpoint || '').trim().toLowerCase(); const source = String(endpoint || '').trim().toLowerCase();
@@ -60,70 +61,23 @@ function getCryptoRuntimeState() {
return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext }; return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext };
} }
async function completeRegistrationLogin({ navigate, keyBundle }) { function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
async function prepareRegistrationLoginForKeyStorage({ keyBundle }) {
await authService.reconnect(state.entrySettings.shineServer); await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.createSessionForExistingUser( const result = await authService.createSessionForExistingUser(
state.registrationDraft.login, state.registrationDraft.login,
state.registrationDraft.password, state.registrationDraft.password,
); );
state.registrationDraft.flowType = 'register';
state.keyStorage.saveRoot = Boolean(state.keyStorage.saveRoot); state.registrationDraft.login = result.login;
state.keyStorage.saveBlockchain = Boolean(state.keyStorage.saveBlockchain); state.registrationDraft.sessionId = result.sessionId;
state.registrationDraft.storagePwd = result.storagePwd;
await authService.persistSelectedKeys( state.registrationDraft.pendingKeyBundle = keyBundle || result.keyBundle;
result.login, state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
result.storagePwd, return result;
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 }) { export function render({ navigate }) {
@@ -299,7 +253,7 @@ export function render({ navigate }) {
} }
} }
renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId }); renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId });
} catch (error) { } catch (error) {
const message = toUserMessage(error, 'Не удалось завершить регистрацию.'); const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
setAuthError(message); setAuthError(message);
@@ -345,7 +299,7 @@ export function render({ navigate }) {
return screen; return screen;
} }
function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) { function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId = '' }) {
const screen = document.querySelector('section.stack'); const screen = document.querySelector('section.stack');
if (!screen) return; if (!screen) return;
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn'); const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
@@ -353,19 +307,24 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
if (!card) return; if (!card) return;
card.classList.add('registration-finish-card'); card.classList.add('registration-finish-card');
let initialDelayId = null; let progressTimerId = null;
let retryTimerId = null; let pollStartTimerId = null;
let loginInFlight = false; let pollTimerId = null;
let loginCompleted = false; 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 txExplorerUrl = makeSolanaExplorerTxUrl(registrationTxId, state.entrySettings.solanaServer);
const title = document.createElement('h2'); const title = document.createElement('h2');
title.className = 'registration-finish-title'; title.className = 'registration-finish-title';
title.textContent = 'Поздравляем, регистрация завершена'; title.textContent = 'Идёт регистрация в блокчейне...';
const hint = document.createElement('p'); const hint = document.createElement('p');
hint.className = 'auth-copy registration-finish-text'; hint.className = 'auth-copy registration-finish-text';
hint.textContent = 'Подождите 10 секунд, пока обновится транзакция вашей регистрации в блокчейне Solana. После этого вход в аккаунт произойдёт автоматически.'; hint.textContent = 'Ждём, пока транзакция будет полностью одобрена сетью Solana.';
const txIdLine = document.createElement('p'); const txIdLine = document.createElement('p');
txIdLine.className = 'meta-muted registration-finish-tx'; txIdLine.className = 'meta-muted registration-finish-tx';
@@ -383,58 +342,218 @@ function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId =
txIdLine.textContent = 'Tx ID регистрации: ожидаем подтверждённую подпись'; 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'); const progress = document.createElement('p');
progress.className = 'meta-muted registration-finish-progress'; progress.className = 'meta-muted registration-finish-progress';
progress.textContent = 'Подготавливаем проверку регистрации...'; progress.textContent = 'Подготавливаем проверку подтверждения...';
const stopAutoLogin = () => { const pollStatus = document.createElement('p');
if (initialDelayId) { pollStatus.className = 'meta-muted registration-finish-progress';
window.clearTimeout(initialDelayId); pollStatus.textContent = 'Через несколько секунд начнём проверять подтверждение регистрации.';
initialDelayId = null;
const stopTimers = () => {
if (progressTimerId) {
window.clearInterval(progressTimerId);
progressTimerId = null;
} }
if (retryTimerId) { if (pollStartTimerId) {
window.clearInterval(retryTimerId); window.clearTimeout(pollStartTimerId);
retryTimerId = null; pollStartTimerId = null;
}
if (pollTimerId) {
window.clearInterval(pollTimerId);
pollTimerId = null;
}
if (successTimerId) {
window.clearTimeout(successTimerId);
successTimerId = null;
} }
}; };
const tryAutoLogin = async () => { const updateVisualProgress = () => {
if (loginCompleted || loginInFlight) return; const elapsed = Date.now() - stageStartedAt;
loginInFlight = true; let widthPercent = 0;
progress.textContent = 'Проверяем, прошла ли регистрация...'; 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'; 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;
renderRegistrationSuccessStage({ 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 { try {
await completeRegistrationLogin({ navigate, keyBundle }); const result = await checkLoginExistsOnSolana({
loginCompleted = true; login: state.registrationDraft.login,
stopAutoLogin(); solanaEndpoint: state.entrySettings.solanaServer,
});
if (result?.exists) {
finalizeSuccess();
return;
}
pollStatus.textContent = 'Пока ещё не прошла, подождите ещё секундочку...';
if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) {
showTimeoutState();
}
} catch (error) { } catch (error) {
if (!loginCompleted) { console.warn('Registration confirmation check failed', toUserMessage(error, 'registration-confirmation'));
progress.textContent = ока ещё не прошла, подождите ещё секундочку...'; pollStatus.textContent = роверяем регистрацию повторно...';
console.warn('Auto login after registration is not ready yet', toUserMessage(error, 'auto-login-wait')); if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) {
showTimeoutState();
} }
} finally { } finally {
loginInFlight = false; confirmationInFlight = false;
} }
}; };
if (headerBackButton) { if (headerBackButton) {
const replacement = headerBackButton.cloneNode(true); const replacement = headerBackButton.cloneNode(true);
replacement.addEventListener('click', () => { replacement.addEventListener('click', () => {
loginCompleted = true; stageClosed = true;
stopAutoLogin(); stopTimers();
navigate('start-view'); navigate('start-view');
}); });
headerBackButton.replaceWith(replacement); headerBackButton.replaceWith(replacement);
} }
card.innerHTML = ''; card.innerHTML = '';
card.append(title, hint, txIdLine, progress, status); status.style.display = 'none';
card.append(title, hint, txIdLine, progressWrap, progress, pollStatus, status);
initialDelayId = window.setTimeout(() => { updateVisualProgress();
if (loginCompleted) return; progressTimerId = window.setInterval(() => {
void tryAutoLogin(); if (stageClosed) return;
retryTimerId = window.setInterval(() => { updateVisualProgress();
void tryAutoLogin(); if ((Date.now() - stageStartedAt) >= REGISTRATION_CONFIRM_TIMEOUT_MS) {
}, AUTO_LOGIN_RETRY_MS); showTimeoutState();
}, AUTO_LOGIN_INITIAL_DELAY_MS); }
}, 160);
pollStartTimerId = window.setTimeout(() => {
if (stageClosed) return;
void tryCheckRegistration();
pollTimerId = window.setInterval(() => {
void tryCheckRegistration();
}, REGISTRATION_POLL_INTERVAL_MS);
}, REGISTRATION_POLL_START_DELAY_MS);
}
function renderRegistrationSuccessStage({ 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;
const txExplorerUrl = makeSolanaExplorerTxUrl(registrationTxId, state.entrySettings.solanaServer);
let loginInFlight = false;
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 enterButton = document.createElement('button');
enterButton.className = 'primary-btn';
enterButton.type = 'button';
enterButton.textContent = 'Войти в аккаунт';
enterButton.addEventListener('click', async () => {
if (loginInFlight) return;
loginInFlight = true;
status.className = 'status-line';
status.textContent = 'Готовим вход и экран сохранения ключей...';
status.style.display = '';
enterButton.disabled = true;
try {
await prepareRegistrationLoginForKeyStorage({ keyBundle });
await clearStoredMessages().catch(() => {});
setAuthInfo(`Регистрация подтверждена для @${state.registrationDraft.login}. Выберите, какие ключи сохранить на устройстве.`);
navigate('registration-keys-view');
} catch (error) {
const message = toUserMessage(error, 'Не удалось подготовить вход после регистрации.');
setAuthError(message);
status.className = 'status-line is-unavailable';
status.textContent = message;
status.style.display = '';
} finally {
loginInFlight = false;
enterButton.disabled = false;
}
});
if (headerBackButton) {
const replacement = headerBackButton.cloneNode(true);
replacement.addEventListener('click', () => navigate('start-view'));
headerBackButton.replaceWith(replacement);
}
card.innerHTML = '';
status.style.display = 'none';
card.append(title, hint, txIdLine, enterButton, status);
} }
+5 -1
View File
@@ -763,7 +763,7 @@
.registration-finish-title { .registration-finish-title {
margin: 0; margin: 0;
font-size: clamp(1.7rem, 6vw, 2.15rem); font-size: clamp(1.9rem, 7vw, 2.45rem);
line-height: 1.18; line-height: 1.18;
text-wrap: balance; text-wrap: balance;
} }
@@ -776,6 +776,10 @@
word-break: break-word; word-break: break-word;
} }
.registration-finish-card .primary-btn {
width: 100%;
}
.registration-finish-tx-link { .registration-finish-tx-link {
color: #9fd8ff; color: #9fd8ff;
text-decoration: underline; text-decoration: underline;