SHA256
Добавить кошелек блокчейна и озвучивание агента
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, clearAuthMessages, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { formatSolanaErrorDetails, precheckLoginClassOnSolana } from '../services/solana-register-service.js';
|
||||
import {
|
||||
checkLoginExistsOnSolana,
|
||||
formatSolanaErrorDetails,
|
||||
precheckLoginClassOnSolana,
|
||||
} from '../services/solana-register-service.js';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
|
||||
@@ -87,8 +91,11 @@ export function render({ navigate }) {
|
||||
checkButton.disabled = true;
|
||||
checkButton.textContent = 'Проверка...';
|
||||
try {
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const isFree = await authService.ensureLoginFree(login);
|
||||
const check = await checkLoginExistsOnSolana({
|
||||
login,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
});
|
||||
const isFree = !check.exists;
|
||||
let className = '';
|
||||
let precheckWarning = '';
|
||||
if (isFree) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
createRandomSolanaWallet,
|
||||
createSolanaWalletFromPrivateBase58,
|
||||
@@ -17,6 +17,14 @@ import {
|
||||
getArweaveWalletFromStoredDeviceKey,
|
||||
transferAr,
|
||||
} from '../services/arweave-wallet-service.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
import {
|
||||
calcLimitTopupPriceLamports,
|
||||
getLimitStepBytes,
|
||||
getShineBlockchainUsage,
|
||||
getShineUsersEconomyConfig,
|
||||
updateShineUserPdaOnSolana,
|
||||
} from '../services/shine-blockchain-wallet-service.js?v=2026052803';
|
||||
|
||||
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
|
||||
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
|
||||
@@ -25,6 +33,20 @@ function nowRu() {
|
||||
return new Date().toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function formatKbFromBytes(rawBytes) {
|
||||
const bytes = typeof rawBytes === 'bigint'
|
||||
? Number(rawBytes)
|
||||
: Number(rawBytes || 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB';
|
||||
const kb = bytes / 1024;
|
||||
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
|
||||
}
|
||||
|
||||
function lamportsToSolText(lamportsBigInt) {
|
||||
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
|
||||
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
|
||||
}
|
||||
|
||||
function createModeBackButton(renderWalletChoice) {
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.className = 'text-btn';
|
||||
@@ -106,11 +128,299 @@ export function render({ navigate }) {
|
||||
void renderArweaveWallet();
|
||||
});
|
||||
|
||||
card.append(solanaBtn, arweaveBtn);
|
||||
const shineBchBtn = document.createElement('button');
|
||||
shineBchBtn.className = 'primary-btn';
|
||||
shineBchBtn.style.width = '100%';
|
||||
shineBchBtn.textContent = 'Блокчейн Сияния';
|
||||
shineBchBtn.addEventListener('click', () => {
|
||||
void renderShineBlockchainWallet();
|
||||
});
|
||||
|
||||
card.append(solanaBtn, arweaveBtn, shineBchBtn);
|
||||
content.append(card);
|
||||
setStatus('Выберите тип кошелька.');
|
||||
}
|
||||
|
||||
async function renderShineBlockchainWallet() {
|
||||
const modeToken = ++activeModeToken;
|
||||
clearArweaveSecretsInMemory();
|
||||
content.innerHTML = '';
|
||||
|
||||
const backBtn = createModeBackButton(renderWalletChoice);
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
const limitLabel = document.createElement('p');
|
||||
limitLabel.className = 'meta-muted';
|
||||
limitLabel.textContent = 'Лимит блокчейна';
|
||||
const limitValue = document.createElement('h2');
|
||||
limitValue.style.fontSize = '26px';
|
||||
limitValue.textContent = '— KB';
|
||||
|
||||
const usedLabel = document.createElement('p');
|
||||
usedLabel.className = 'meta-muted';
|
||||
usedLabel.textContent = 'Израсходовано (закреплено в Solana)';
|
||||
const usedValue = document.createElement('h2');
|
||||
usedValue.style.fontSize = '26px';
|
||||
usedValue.textContent = '— KB';
|
||||
|
||||
const leftLabel = document.createElement('p');
|
||||
leftLabel.className = 'meta-muted';
|
||||
leftLabel.textContent = 'Осталось (по Solana)';
|
||||
const leftValue = document.createElement('h2');
|
||||
leftValue.style.fontSize = '30px';
|
||||
leftValue.textContent = '— KB';
|
||||
|
||||
const pdaLabel = document.createElement('p');
|
||||
pdaLabel.className = 'meta-muted';
|
||||
pdaLabel.style.wordBreak = 'break-all';
|
||||
pdaLabel.textContent = 'PDA: —';
|
||||
|
||||
const endpointLabel = document.createElement('p');
|
||||
endpointLabel.className = 'meta-muted';
|
||||
endpointLabel.textContent = `RPC: ${state.entrySettings.solanaServer}`;
|
||||
|
||||
const updatedLabel = document.createElement('p');
|
||||
updatedLabel.className = 'meta-muted';
|
||||
updatedLabel.textContent = 'Обновлено: —';
|
||||
|
||||
const serverTitle = document.createElement('h3');
|
||||
serverTitle.style.margin = '8px 0 0';
|
||||
serverTitle.textContent = 'Фактическое состояние на сервере';
|
||||
const serverBlocksLabel = document.createElement('p');
|
||||
serverBlocksLabel.className = 'meta-muted';
|
||||
serverBlocksLabel.textContent = 'Блоков: —';
|
||||
const serverSizeLabel = document.createElement('p');
|
||||
serverSizeLabel.className = 'meta-muted';
|
||||
serverSizeLabel.textContent = 'Размер цепочки: —';
|
||||
const serverLastLabel = document.createElement('p');
|
||||
serverLastLabel.className = 'meta-muted';
|
||||
serverLastLabel.textContent = 'Крайний блок: —';
|
||||
const serverLastHashLabel = document.createElement('p');
|
||||
serverLastHashLabel.className = 'meta-muted';
|
||||
serverLastHashLabel.style.wordBreak = 'break-all';
|
||||
serverLastHashLabel.textContent = 'Hash: —';
|
||||
|
||||
const solanaTitle = document.createElement('h3');
|
||||
solanaTitle.style.margin = '8px 0 0';
|
||||
solanaTitle.textContent = 'Закреплено в Solana';
|
||||
const solanaBlocksLabel = document.createElement('p');
|
||||
solanaBlocksLabel.className = 'meta-muted';
|
||||
solanaBlocksLabel.textContent = 'Блоков: —';
|
||||
const solanaLastLabel = document.createElement('p');
|
||||
solanaLastLabel.className = 'meta-muted';
|
||||
solanaLastLabel.textContent = 'Крайний блок: —';
|
||||
const solanaLastHashLabel = document.createElement('p');
|
||||
solanaLastHashLabel.className = 'meta-muted';
|
||||
solanaLastHashLabel.style.wordBreak = 'break-all';
|
||||
solanaLastHashLabel.textContent = 'Hash: —';
|
||||
|
||||
card.append(
|
||||
limitLabel,
|
||||
limitValue,
|
||||
usedLabel,
|
||||
usedValue,
|
||||
leftLabel,
|
||||
leftValue,
|
||||
pdaLabel,
|
||||
endpointLabel,
|
||||
updatedLabel,
|
||||
serverTitle,
|
||||
serverBlocksLabel,
|
||||
serverSizeLabel,
|
||||
serverLastLabel,
|
||||
serverLastHashLabel,
|
||||
solanaTitle,
|
||||
solanaBlocksLabel,
|
||||
solanaLastLabel,
|
||||
solanaLastHashLabel,
|
||||
);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'stack';
|
||||
actions.innerHTML = `
|
||||
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
|
||||
<button class="primary-btn" id="sync-shine-solana" style="width:100%;">Закрепить в Solana</button>
|
||||
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
|
||||
`;
|
||||
const refreshBtn = actions.querySelector('#refresh-shine-bch');
|
||||
const syncBtn = actions.querySelector('#sync-shine-solana');
|
||||
const topupBtn = actions.querySelector('#topup-shine-limit');
|
||||
|
||||
const fetchServerState = async () => {
|
||||
const user = await authService.getUser(String(state.session.login || '').trim());
|
||||
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
|
||||
return {
|
||||
blocksCount: Number(user.serverBlocksCount || 0),
|
||||
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
|
||||
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
|
||||
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
|
||||
lastHash: String(user.serverLastGlobalHash || ''),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveWalletSigningMaterial = async () => {
|
||||
const { login, storagePwd } = sessionArgsOrThrow();
|
||||
let saved;
|
||||
try {
|
||||
saved = await loadEncryptedUserSecrets(login, storagePwd);
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
let rootKey = String(saved?.rootKey || '').trim();
|
||||
let blockchainKey = String(saved?.blockchainKey || '').trim();
|
||||
const deviceKey = String(saved?.deviceKey || '').trim();
|
||||
if (!deviceKey) throw new Error('На устройстве нет device.key. Выполните вход заново.');
|
||||
if (rootKey && blockchainKey) {
|
||||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey };
|
||||
}
|
||||
|
||||
const password = window.prompt(
|
||||
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
|
||||
'',
|
||||
);
|
||||
if (password == null) throw new Error('Операция отменена пользователем');
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||||
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
|
||||
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
|
||||
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
|
||||
|
||||
const shouldSave = window.confirm(
|
||||
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
|
||||
);
|
||||
if (shouldSave) {
|
||||
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
|
||||
}
|
||||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey };
|
||||
};
|
||||
|
||||
const setButtonsDisabled = (disabled) => {
|
||||
refreshBtn.disabled = disabled;
|
||||
syncBtn.disabled = disabled;
|
||||
topupBtn.disabled = disabled;
|
||||
};
|
||||
|
||||
const refreshUsage = async () => {
|
||||
setButtonsDisabled(true);
|
||||
try {
|
||||
const [usage, serverState] = await Promise.all([
|
||||
getShineBlockchainUsage({
|
||||
login: String(state.session.login || '').trim(),
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
}),
|
||||
fetchServerState(),
|
||||
]);
|
||||
if (modeToken !== activeModeToken) return;
|
||||
const solanaBlocks = usage.lastBlockNumber >= 0 ? usage.lastBlockNumber + 1 : 0;
|
||||
const serverBlocks = serverState.blocksCount;
|
||||
|
||||
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
|
||||
usedValue.textContent = formatKbFromBytes(usage.usedBytes);
|
||||
leftValue.textContent = formatKbFromBytes(usage.leftBytes);
|
||||
pdaLabel.textContent = `PDA: ${usage.userPda}`;
|
||||
endpointLabel.textContent = `RPC: ${usage.endpoint}`;
|
||||
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
|
||||
|
||||
serverBlocksLabel.textContent = `Блоков: ${serverBlocks.toLocaleString('ru-RU')}`;
|
||||
serverSizeLabel.textContent = `Размер цепочки: ${formatKbFromBytes(serverState.sizeBytes)} из ${formatKbFromBytes(serverState.sizeLimitBytes)}`;
|
||||
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
|
||||
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
|
||||
|
||||
solanaBlocksLabel.textContent = `Блоков: ${solanaBlocks.toLocaleString('ru-RU')}`;
|
||||
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
|
||||
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
|
||||
|
||||
setStatus('Данные лимита и состояния блокчейна обновлены.');
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Не удалось обновить состояние блокчейна: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
syncBtn.addEventListener('click', async () => {
|
||||
setButtonsDisabled(true);
|
||||
try {
|
||||
const [serverState, signing] = await Promise.all([
|
||||
fetchServerState(),
|
||||
resolveWalletSigningMaterial(),
|
||||
]);
|
||||
const result = await updateShineUserPdaOnSolana({
|
||||
login: String(state.session.login || '').trim(),
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||||
devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64,
|
||||
additionalLimitBytes: 0n,
|
||||
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
|
||||
nextLastBlockNumber: serverState.lastNumber,
|
||||
nextLastBlockHashHex: serverState.lastHash,
|
||||
});
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Состояние закреплено в Solana. Tx: ${result.signature}`);
|
||||
await refreshUsage();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Не удалось закрепить состояние в Solana: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
topupBtn.addEventListener('click', async () => {
|
||||
setButtonsDisabled(true);
|
||||
try {
|
||||
const economy = await getShineUsersEconomyConfig({ solanaEndpoint: state.entrySettings.solanaServer });
|
||||
const step = getLimitStepBytes();
|
||||
const input = window.prompt(
|
||||
`Введите, на сколько увеличить лимит (в байтах, шаг ${step.toString()}).\nЦена за шаг: ${lamportsToSolText(economy.lamportsPerLimitStep)} SOL`,
|
||||
step.toString(),
|
||||
);
|
||||
if (!input) {
|
||||
setStatus('Увеличение лимита отменено.');
|
||||
return;
|
||||
}
|
||||
const addBytes = BigInt(String(input).trim());
|
||||
const priceLamports = calcLimitTopupPriceLamports(addBytes, economy.lamportsPerLimitStep);
|
||||
const confirm = window.confirm(
|
||||
`Будет увеличено на ${formatKbFromBytes(addBytes)}.\n` +
|
||||
`С вашего Solana-счёта будет списано ~${lamportsToSolText(priceLamports)} SOL.\n` +
|
||||
`Продолжить?`,
|
||||
);
|
||||
if (!confirm) {
|
||||
setStatus('Увеличение лимита отменено.');
|
||||
return;
|
||||
}
|
||||
const signing = await resolveWalletSigningMaterial();
|
||||
const result = await updateShineUserPdaOnSolana({
|
||||
login: String(state.session.login || '').trim(),
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||||
devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64,
|
||||
additionalLimitBytes: addBytes,
|
||||
});
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Лимит увеличен. Tx: ${result.signature}`);
|
||||
await refreshUsage();
|
||||
} catch (error) {
|
||||
if (modeToken !== activeModeToken) return;
|
||||
setStatus(`Не удалось увеличить лимит: ${error?.message || 'unknown'}`);
|
||||
} finally {
|
||||
setButtonsDisabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
void refreshUsage();
|
||||
});
|
||||
|
||||
content.append(backBtn, card, actions);
|
||||
setStatus('Загрузка данных блокчейна Сияния...');
|
||||
await refreshUsage();
|
||||
}
|
||||
|
||||
async function renderSolanaWallet() {
|
||||
const modeToken = ++activeModeToken;
|
||||
clearArweaveSecretsInMemory();
|
||||
|
||||
Reference in New Issue
Block a user