Синхронизировать регистр логинов и доработать UI каналов

This commit is contained in:
AidarKC
2026-08-19 14:30:29 +04:00
parent e8a4713f6e
commit ce30b77328
29 changed files with 6729 additions and 815 deletions
+429 -84
View File
@@ -17,6 +17,8 @@ import {
} from '../services/arweave-wallet-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl, sha256HexFromArrayBuffer } from '../services/arweave-file-service.js';
import {
calcLimitTopupPriceLamports,
getLimitStepBytes,
@@ -24,6 +26,12 @@ import {
getShineUsersEconomyConfig,
updateShineUserPdaOnSolana,
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
import {
buildBlockchainSnapshotFile,
clearStoredBlockchainSnapshot,
readStoredBlockchainSnapshot,
saveStoredBlockchainSnapshot,
} from '../services/shine-blockchain-snapshot-service.js';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
@@ -40,6 +48,17 @@ function formatKbFromBytes(rawBytes) {
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
}
function formatBytesRu(rawBytes) {
const bytes = typeof rawBytes === 'bigint' ? Number(rawBytes) : Number(rawBytes || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
return bytes.toLocaleString('ru-RU');
}
function normalizeHex64(value) {
const raw = String(value || '').trim().toLowerCase();
return /^[0-9a-f]{64}$/.test(raw) ? raw : '';
}
function lamportsToSolText(lamportsBigInt) {
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
@@ -461,6 +480,55 @@ export function render({ navigate }) {
arweaveWalletCtx = null;
}
async function fetchServerBlockchainState() {
const login = String(state.session.login || '').trim();
const user = await authService.getUser(login);
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
return {
login,
blockchainName: String(user.blockchainName || `${login}-001`).trim(),
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
lastHash: String(user.serverLastGlobalHash || '').trim().toLowerCase(),
};
}
async function resolveWalletSigningMaterial() {
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 clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
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, clientPrivatePkcs8B64: clientKey };
}
function styleSupportInputField(field) {
if (!field) return;
field.style.color = '#111111';
@@ -1245,6 +1313,14 @@ export function render({ navigate }) {
void renderShineBlockchainWallet();
});
const solanaPublishBtn = document.createElement('button');
solanaPublishBtn.className = 'primary-btn';
solanaPublishBtn.style.width = '100%';
solanaPublishBtn.textContent = 'Закрепление в Solana';
solanaPublishBtn.addEventListener('click', () => {
void renderSolanaPublishWallet();
});
const supportBtn = document.createElement('button');
supportBtn.className = 'primary-btn';
supportBtn.style.width = '100%';
@@ -1253,7 +1329,7 @@ export function render({ navigate }) {
void renderSupportHub();
});
card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn);
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
content.append(card);
setStatus('Выберите тип кошелька.');
}
@@ -1339,63 +1415,13 @@ export function render({ navigate }) {
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('Пользователь не найден на сервере');
const lastNumber = Number(user.serverLastGlobalNumber ?? -1);
return {
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber,
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 clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
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, clientPrivatePkcs8B64: clientKey };
};
const setButtonsDisabled = (disabled) => {
refreshBtn.disabled = disabled;
syncBtn.disabled = disabled;
topupBtn.disabled = disabled;
};
@@ -1407,7 +1433,7 @@ export function render({ navigate }) {
login: String(state.session.login || '').trim(),
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerState(),
fetchServerBlockchainState(),
]);
if (modeToken !== activeModeToken) return;
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
@@ -1423,9 +1449,6 @@ export function render({ navigate }) {
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
setStatus('Данные лимита и состояния блокчейна обновлены.');
} catch (error) {
if (modeToken !== activeModeToken) return;
@@ -1435,35 +1458,6 @@ export function render({ navigate }) {
}
};
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,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
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 {
@@ -1517,6 +1511,357 @@ export function render({ navigate }) {
await refreshUsage();
}
async function renderSolanaPublishWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const login = String(state.session.login || '').trim();
const backBtn = createModeBackButton(renderWalletChoice);
const summaryCard = document.createElement('div');
summaryCard.className = 'card stack';
summaryCard.innerHTML = `
<h2 style="margin:0;">Закрепление в Solana</h2>
<p class="meta-muted" style="margin:0;">Сначала делаем полный слепок пользовательского блокчейна в Arweave или Turbo, затем обновляем ссылку в PDA.</p>
`;
const publicationStatus = document.createElement('div');
publicationStatus.className = 'card stack';
publicationStatus.innerHTML = `
<h3 style="margin:0;">Статус публикации</h3>
<p class="meta-muted" id="publish-status-main">—</p>
<p class="meta-muted" id="publish-status-local">—</p>
`;
const publicationMainEl = publicationStatus.querySelector('#publish-status-main');
const publicationLocalEl = publicationStatus.querySelector('#publish-status-local');
const pdaCard = document.createElement('div');
pdaCard.className = 'card stack';
pdaCard.innerHTML = `
<h3 style="margin:0;">Что закреплено в PDA</h3>
<p class="meta-muted" id="pda-name">Блокчейн: —</p>
<p class="meta-muted" id="pda-address" style="word-break:break-all;">PDA: —</p>
<p class="meta-muted" id="pda-last">Крайний блок: —</p>
<p class="meta-muted" id="pda-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="pda-used">Размер: —</p>
<p class="meta-muted" id="pda-limit">Лимит: —</p>
<p class="meta-muted" id="pda-arweave" style="word-break:break-all;">Arweave tx: —</p>
<a class="text-btn" id="pda-arweave-link" href="#" target="_blank" rel="noreferrer noopener" style="pointer-events:none; opacity:.55; padding:0;">Открыть Arweave</a>
`;
const serverCard = document.createElement('div');
serverCard.className = 'card stack';
serverCard.innerHTML = `
<h3 style="margin:0;">Что реально на сервере</h3>
<p class="meta-muted" id="server-name">Блокчейн: —</p>
<p class="meta-muted" id="server-last">Крайний блок: —</p>
<p class="meta-muted" id="server-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="server-size">Размер: —</p>
<p class="meta-muted" id="server-updated">Обновлено: —</p>
`;
const localCard = document.createElement('div');
localCard.className = 'card stack';
localCard.innerHTML = `
<h3 style="margin:0;">Локальный слепок в этом браузере</h3>
<p class="meta-muted" id="local-state">Слепок: —</p>
<p class="meta-muted" id="local-transport">Способ: —</p>
<p class="meta-muted" id="local-last">Крайний блок: —</p>
<p class="meta-muted" id="local-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="local-size">Размер: —</p>
<p class="meta-muted" id="local-arweave" style="word-break:break-all;">Arweave tx: —</p>
<p class="meta-muted" id="local-created">Создан: —</p>
<p class="meta-muted" id="local-solana">Solana: —</p>
`;
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<button class="ghost-btn" id="refresh-solana-publish" style="width:100%;">Обновить состояние</button>
<button class="primary-btn" id="create-solana-snapshot" style="width:100%;">Сделать слепок в Arweave / Turbo</button>
<button class="primary-btn" id="publish-solana-snapshot" style="width:100%;">Обновить Solana по готовому слепку</button>
<button class="text-btn" id="clear-solana-snapshot" style="width:100%;">Забыть локальный слепок в этом браузере</button>
`;
const refreshBtn = actions.querySelector('#refresh-solana-publish');
const createBtn = actions.querySelector('#create-solana-snapshot');
const publishBtn = actions.querySelector('#publish-solana-snapshot');
const clearBtn = actions.querySelector('#clear-solana-snapshot');
const pdaNameEl = pdaCard.querySelector('#pda-name');
const pdaAddressEl = pdaCard.querySelector('#pda-address');
const pdaLastEl = pdaCard.querySelector('#pda-last');
const pdaHashEl = pdaCard.querySelector('#pda-hash');
const pdaUsedEl = pdaCard.querySelector('#pda-used');
const pdaLimitEl = pdaCard.querySelector('#pda-limit');
const pdaArweaveEl = pdaCard.querySelector('#pda-arweave');
const pdaArweaveLinkEl = pdaCard.querySelector('#pda-arweave-link');
const serverNameEl = serverCard.querySelector('#server-name');
const serverLastEl = serverCard.querySelector('#server-last');
const serverHashEl = serverCard.querySelector('#server-hash');
const serverSizeEl = serverCard.querySelector('#server-size');
const serverUpdatedEl = serverCard.querySelector('#server-updated');
const localStateEl = localCard.querySelector('#local-state');
const localTransportEl = localCard.querySelector('#local-transport');
const localLastEl = localCard.querySelector('#local-last');
const localHashEl = localCard.querySelector('#local-hash');
const localSizeEl = localCard.querySelector('#local-size');
const localArweaveEl = localCard.querySelector('#local-arweave');
const localCreatedEl = localCard.querySelector('#local-created');
const localSolanaEl = localCard.querySelector('#local-solana');
function setButtonsDisabled(disabled) {
refreshBtn.disabled = disabled;
createBtn.disabled = disabled;
publishBtn.disabled = disabled;
clearBtn.disabled = disabled;
}
async function refreshState() {
setButtonsDisabled(true);
try {
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
]);
const localSnapshot = readStoredBlockchainSnapshot(login);
if (modeToken !== activeModeToken) return;
const pdaDelta = Math.max(0, Number(serverState.lastNumber || 0) - Number(usage.lastBlockNumber || 0));
if (!usage.arweaveTxId) {
publicationMainEl.textContent = 'не опубликовано';
} else if (pdaDelta > 0) {
publicationMainEl.textContent = `опубликовано, отстаёт на ${pdaDelta} блоков`;
} else {
publicationMainEl.textContent = 'опубликовано и полностью актуально';
}
let localStatusText = 'Локального слепка нет.';
const localMatchesServer = localSnapshot
&& String(localSnapshot.blockchainName || '') === String(usage.blockchainName || serverState.blockchainName || '')
&& Number(localSnapshot.lastBlockNumber) === Number(serverState.lastNumber)
&& normalizeHex64(localSnapshot.lastBlockHash) === normalizeHex64(serverState.lastHash)
&& Number(localSnapshot.usedBytes || 0) === Number(serverState.sizeBytes || 0);
if (localSnapshot?.txId && localMatchesServer) {
if (String(localSnapshot.txId || '') === String(usage.arweaveTxId || '')) {
localStatusText = 'Локальный слепок уже совпадает с тем, что закреплено в Solana.';
} else {
localStatusText = 'Слепок уже есть, но Solana ещё не обновлена. Лучше подождать 1-2 минуты и затем обновить PDA.';
}
} else if (localSnapshot?.txId) {
localStatusText = 'Есть локальный слепок, но он уже устарел относительно сервера.';
}
publicationLocalEl.textContent = localStatusText;
pdaNameEl.textContent = `Блокчейн: ${usage.blockchainName || '—'}`;
pdaAddressEl.textContent = `PDA: ${usage.userPda || '—'}`;
pdaLastEl.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
pdaHashEl.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
pdaUsedEl.textContent = `Размер: ${formatBytesRu(usage.usedBytes)} байт`;
pdaLimitEl.textContent = `Лимит: ${formatBytesRu(usage.paidLimitBytes)} байт`;
pdaArweaveEl.textContent = `Arweave tx: ${usage.arweaveTxId || '—'}`;
if (usage.arweaveTxId) {
pdaArweaveLinkEl.href = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: usage.arweaveTxId });
pdaArweaveLinkEl.style.pointerEvents = 'auto';
pdaArweaveLinkEl.style.opacity = '1';
} else {
pdaArweaveLinkEl.href = '#';
pdaArweaveLinkEl.style.pointerEvents = 'none';
pdaArweaveLinkEl.style.opacity = '.55';
}
serverNameEl.textContent = `Блокчейн: ${serverState.blockchainName || usage.blockchainName || '—'}`;
serverLastEl.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverHashEl.textContent = `Hash: ${serverState.lastHash || '—'}`;
serverSizeEl.textContent = `Размер: ${formatBytesRu(serverState.sizeBytes)} байт`;
serverUpdatedEl.textContent = `Обновлено: ${nowRu()}`;
if (localSnapshot?.txId) {
localStateEl.textContent = `Слепок: ${String(localSnapshot.txId || '') === String(usage.arweaveTxId || '') ? 'закреплён' : 'есть, но не закреплён в Solana'}`;
localTransportEl.textContent = `Способ: ${localSnapshot.uploadTransport === 'turbo' ? 'Turbo' : (localSnapshot.uploadTransport === 'arweave' ? 'Arweave' : '—')}`;
localLastEl.textContent = `Крайний блок: ${localSnapshot.lastBlockNumber ?? '—'}`;
localHashEl.textContent = `Hash: ${localSnapshot.lastBlockHash || '—'}`;
localSizeEl.textContent = `Размер: ${formatBytesRu(localSnapshot.usedBytes || 0)} байт`;
localArweaveEl.textContent = `Arweave tx: ${localSnapshot.txId}`;
localCreatedEl.textContent = `Создан: ${localSnapshot.uploadedAtMs ? new Date(localSnapshot.uploadedAtMs).toLocaleString('ru-RU') : '—'}`;
localSolanaEl.textContent = localSnapshot.solanaUpdatedAtMs
? `Solana обновлена: ${new Date(localSnapshot.solanaUpdatedAtMs).toLocaleString('ru-RU')}`
: 'Solana: слепок ещё не закреплён';
} else {
localStateEl.textContent = 'Слепок: отсутствует';
localTransportEl.textContent = 'Способ: —';
localLastEl.textContent = 'Крайний блок: —';
localHashEl.textContent = 'Hash: —';
localSizeEl.textContent = 'Размер: —';
localArweaveEl.textContent = 'Arweave tx: —';
localCreatedEl.textContent = 'Создан: —';
localSolanaEl.textContent = 'Solana: —';
}
publishBtn.disabled = !localSnapshot?.txId || !localMatchesServer || String(localSnapshot.txId || '') === String(usage.arweaveTxId || '');
clearBtn.disabled = !localSnapshot;
setStatus('Состояние публикации обновлено.');
} catch (error) {
if (modeToken !== activeModeToken) return;
publishBtn.disabled = true;
setStatus(`Не удалось обновить состояние публикации: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
createBtn.disabled = false;
}
}
async function createSnapshot() {
setButtonsDisabled(true);
try {
setStatus('Собираю полный слепок блокчейна с сервера...');
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
]);
const snapshotFile = await buildBlockchainSnapshotFile({
authService,
login,
blockchainName: usage.blockchainName || serverState.blockchainName,
lastBlockNumber: serverState.lastNumber,
});
const snapshotSha256 = await sha256HexFromArrayBuffer(await snapshotFile.file.arrayBuffer());
if (modeToken !== activeModeToken) return;
setStatus('Слепок собран. Открываю менеджер загрузки Arweave / Turbo...');
const uploaded = await openArweaveAttachmentManager({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
historyOnly: false,
persistToHistory: false,
allowHistorySelection: false,
allowExistingTxInput: false,
uploadTransport: 'turbo',
dialogTitle: 'Сделать слепок блокчейна',
uploadButtonLabel: 'Сделать слепок',
initialFile: snapshotFile.file,
initialSha256: snapshotSha256,
initialName: `${snapshotFile.blockchainName}.shine-blockchain`,
fixedFile: true,
autoOpenFileDialog: false,
shineType: 'blockchain-snapshot',
extraUploadTags: [
{ name: 'SHiNE-Blockchain-Snapshot', value: '1' },
{ name: 'SHiNE-Login', value: login },
{ name: 'SHiNE-Blockchain-Name', value: snapshotFile.blockchainName },
{ name: 'SHiNE-Last-Block-Number', value: String(serverState.lastNumber) },
{ name: 'SHiNE-Last-Block-Hash', value: String(serverState.lastHash || '') },
],
});
if (modeToken !== activeModeToken) return;
if (!uploaded?.ar) {
setStatus('Загрузка слепка отменена.');
return;
}
saveStoredBlockchainSnapshot(login, {
blockchainName: snapshotFile.blockchainName,
txId: String(uploaded.ar || '').trim(),
sha256: String(uploaded.sha256 || snapshotSha256).trim().toLowerCase(),
uploadTransport: String(uploaded.uploadTransport || '').trim().toLowerCase(),
usedBytes: Number(serverState.sizeBytes || 0),
lastBlockNumber: Number(serverState.lastNumber),
lastBlockHash: String(serverState.lastHash || '').trim().toLowerCase(),
uploadedAtMs: Date.now(),
});
setStatus('Слепок создан. Лучше подождать 1-2 минуты и затем обновить PDA в Solana.');
await refreshState();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось сделать слепок: ${error?.message || 'unknown'}`);
} finally {
if (modeToken === activeModeToken) {
setButtonsDisabled(false);
}
}
}
async function publishSnapshotToSolana() {
setButtonsDisabled(true);
try {
const localSnapshot = readStoredBlockchainSnapshot(login);
if (!localSnapshot?.txId) throw new Error('Сначала сделайте слепок в Arweave или Turbo.');
const [usage, serverState, signing] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
resolveWalletSigningMaterial(),
]);
if (String(localSnapshot.blockchainName || '') !== String(usage.blockchainName || serverState.blockchainName || '')) {
throw new Error('Локальный слепок относится к другому блокчейну.');
}
if (Number(localSnapshot.lastBlockNumber) !== Number(serverState.lastNumber)
|| normalizeHex64(localSnapshot.lastBlockHash) !== normalizeHex64(serverState.lastHash)
|| Number(localSnapshot.usedBytes || 0) !== Number(serverState.sizeBytes || 0)) {
throw new Error('Локальный слепок устарел. Сделайте новый слепок для текущей вершины.');
}
const result = await updateShineUserPdaOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: 0n,
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
nextLastBlockNumber: serverState.lastNumber,
nextLastBlockHashHex: serverState.lastHash,
nextArweaveTxId: localSnapshot.txId,
});
if (modeToken !== activeModeToken) return;
saveStoredBlockchainSnapshot(login, {
...localSnapshot,
solanaUpdatedAtMs: Date.now(),
solanaSignature: String(result.signature || '').trim(),
});
setStatus(`Слепок закреплён в Solana. Tx: ${result.signature}`);
await refreshState();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось обновить Solana: ${error?.message || 'unknown'}`);
} finally {
if (modeToken === activeModeToken) {
setButtonsDisabled(false);
}
}
}
refreshBtn.addEventListener('click', () => {
void refreshState();
});
createBtn.addEventListener('click', () => {
void createSnapshot();
});
publishBtn.addEventListener('click', () => {
void publishSnapshotToSolana();
});
clearBtn.addEventListener('click', () => {
clearStoredBlockchainSnapshot(login);
setStatus('Локальный слепок удалён из браузера.');
void refreshState();
});
content.append(backBtn, summaryCard, publicationStatus, pdaCard, serverCard, localCard, actions);
setStatus('Загрузка состояния публикации...');
await refreshState();
}
async function renderSolanaWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();