SHA256
Синхронизировать регистр логинов и доработать UI каналов
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user