SHiNE-server/shine-UI/js/pages/wallet-view.js
2026-06-22 21:57:09 +04:00

931 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import {
createRandomSolanaWallet,
createSolanaWalletFromPrivateBase58,
formatSol,
getBalanceSol,
getTopupSiteUrl,
getWalletFromStoredClientKey,
transferSol,
} from '../services/solana-wallet-service.js';
import {
formatAr,
getArweaveBalance,
getArweaveTopupSiteUrl,
getArweaveWalletFromStoredClientKey,
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=202605300007';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
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';
backBtn.textContent = '← К выбору кошелька';
backBtn.addEventListener('click', () => {
renderWalletChoice();
});
return backBtn;
}
function sessionArgsOrThrow() {
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) {
throw new Error('Нет активной сессии. Выполните вход заново.');
}
return { login, storagePwd };
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const status = document.createElement('p');
status.className = 'meta-muted';
const setStatus = (text) => {
status.textContent = String(text || '');
};
const content = document.createElement('div');
content.className = 'stack';
screen.append(
renderHeader({
title: 'Кошелёк',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
content,
status,
);
let activeModeToken = 0;
let arweaveWalletCtx = null;
function clearArweaveSecretsInMemory() {
if (!arweaveWalletCtx?.jwk) return;
Object.keys(arweaveWalletCtx.jwk).forEach((key) => {
arweaveWalletCtx.jwk[key] = '';
});
arweaveWalletCtx.jwk = null;
arweaveWalletCtx = null;
}
function renderWalletChoice() {
activeModeToken += 1;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<h2 style="margin:0 0 6px;">Кошелёк</h2>
<p class="meta-muted">Выберите режим кошелька.</p>
`;
const solanaBtn = document.createElement('button');
solanaBtn.className = 'primary-btn';
solanaBtn.style.width = '100%';
solanaBtn.textContent = 'Solana кошелёк';
solanaBtn.addEventListener('click', () => {
void renderSolanaWallet();
});
const arweaveBtn = document.createElement('button');
arweaveBtn.className = 'primary-btn';
arweaveBtn.style.width = '100%';
arweaveBtn.textContent = 'Arweave кошелёк';
arweaveBtn.addEventListener('click', () => {
void renderArweaveWallet();
});
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 = 'Израсходовано (фактически на сервере)';
const usedValue = document.createElement('h2');
usedValue.style.fontSize = '26px';
usedValue.textContent = '— KB';
const leftLabel = document.createElement('p');
leftLabel.className = 'meta-muted';
leftLabel.textContent = 'Осталось';
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 = '14px 0 0';
serverTitle.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.style.fontSize = '11px';
serverLastHashLabel.textContent = 'Hash: —';
const solanaTitle = document.createElement('h3');
solanaTitle.style.margin = '14px 0 0';
solanaTitle.textContent = 'Закреплено в Solana';
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.style.fontSize = '11px';
solanaLastHashLabel.textContent = 'Hash: —';
card.append(
limitLabel,
limitValue,
usedLabel,
usedValue,
leftLabel,
leftValue,
pdaLabel,
endpointLabel,
updatedLabel,
serverTitle,
serverSizeLabel,
serverLastLabel,
serverLastHashLabel,
solanaTitle,
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('Пользователь не найден на сервере');
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;
};
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;
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
const usedServerBytes = BigInt(Math.max(0, Number(serverState.sizeBytes || 0)));
const leftServerBytes = usage.paidLimitBytes > usedServerBytes ? (usage.paidLimitBytes - usedServerBytes) : 0n;
usedValue.textContent = formatKbFromBytes(usedServerBytes);
leftValue.textContent = formatKbFromBytes(leftServerBytes);
pdaLabel.textContent = `PDA: ${usage.userPda}`;
endpointLabel.textContent = `RPC: ${usage.endpoint}`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
serverSizeLabel.textContent = `Размер цепочки: ${formatKbFromBytes(serverState.sizeBytes)}`;
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;
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,
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 {
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,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
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();
content.innerHTML = '';
let walletCtx = null;
let walletAddress = '';
const backBtn = createModeBackButton(renderWalletChoice);
const card = document.createElement('div');
card.className = 'card stack';
const balanceWrap = document.createElement('div');
const balanceLabel = document.createElement('p');
balanceLabel.className = 'meta-muted';
balanceLabel.textContent = 'Баланс (Solana)';
const balanceValue = document.createElement('h2');
balanceValue.style.fontSize = '30px';
balanceValue.textContent = '— SOL';
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
const endpointLabel = document.createElement('p');
endpointLabel.className = 'meta-muted';
endpointLabel.textContent = `RPC: ${state.entrySettings.solanaServer}`;
balanceWrap.append(balanceLabel, balanceValue, updatedLabel, endpointLabel);
const addressCard = document.createElement('div');
addressCard.className = 'card';
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (client.key)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
card.append(balanceWrap, addressCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<div class="row">
<button class="text-btn" id="copy-address">Копировать адрес</button>
<button class="ghost-btn" id="refresh-balance">Обновить баланс</button>
</div>
<div class="row">
<button class="primary-btn" id="send-sol" style="width:100%;">Перевести</button>
<button class="primary-btn" id="topup-sol" style="width:100%;">Пополнить</button>
</div>
`;
const copyBtn = actions.querySelector('#copy-address');
const refreshBtn = actions.querySelector('#refresh-balance');
const sendBtn = actions.querySelector('#send-sol');
const topupBtn = actions.querySelector('#topup-sol');
const generatedCard = document.createElement('div');
generatedCard.className = 'card stack';
generatedCard.innerHTML = `
<h3 style="margin:0;">Создание нового кошелька Solana</h3>
<p class="meta-muted" style="margin:0;">Введите приватный ключ Base58 (32 байта) или сгенерируйте случайный.</p>
`;
const privateLabel = document.createElement('label');
privateLabel.className = 'meta-muted';
privateLabel.textContent = 'Приватный ключ (Base58, 32 байта)';
privateLabel.setAttribute('for', 'solana-private-base58-input');
const privateInput = document.createElement('input');
privateInput.id = 'solana-private-base58-input';
privateInput.type = 'text';
privateInput.placeholder = 'Введите приватный ключ Base58';
privateInput.maxLength = SOLANA_PRIVATE_BASE58_MAX_LEN;
privateInput.autocomplete = 'off';
privateInput.spellcheck = false;
const privateState = document.createElement('p');
privateState.className = 'meta-muted';
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
const generatedPublicLabel = document.createElement('label');
generatedPublicLabel.className = 'meta-muted';
generatedPublicLabel.textContent = 'Публичный ключ (Base58)';
generatedPublicLabel.setAttribute('for', 'solana-generated-public-key');
const generatedPublicInput = document.createElement('input');
generatedPublicInput.id = 'solana-generated-public-key';
generatedPublicInput.type = 'text';
generatedPublicInput.readOnly = true;
generatedPublicInput.placeholder = 'Будет сгенерирован после нажатия кнопки';
const generatedPrivateLabel = document.createElement('label');
generatedPrivateLabel.className = 'meta-muted';
generatedPrivateLabel.textContent = 'Сгенерированный приватный ключ (Base58)';
generatedPrivateLabel.setAttribute('for', 'solana-generated-private-key');
const generatedPrivateInput = document.createElement('input');
generatedPrivateInput.id = 'solana-generated-private-key';
generatedPrivateInput.type = 'text';
generatedPrivateInput.readOnly = true;
generatedPrivateInput.placeholder = 'Появится после генерации';
const generationActions = document.createElement('div');
generationActions.className = 'row';
generationActions.innerHTML = `
<button class="primary-btn" id="generate-random-solana" style="width:100%;">Сгенерировать случайный кошелёк</button>
<button class="primary-btn" id="generate-from-private-solana" style="width:100%;">Сгенерировать из приватного ключа</button>
`;
const copyGeneratedActions = document.createElement('div');
copyGeneratedActions.className = 'row';
copyGeneratedActions.innerHTML = `
<button class="text-btn" id="copy-generated-private-solana" style="width:100%;">Копировать приватный</button>
<button class="text-btn" id="copy-generated-public-solana" style="width:100%;">Копировать публичный</button>
`;
generatedCard.append(
privateLabel,
privateInput,
privateState,
generationActions,
generatedPrivateLabel,
generatedPrivateInput,
generatedPublicLabel,
generatedPublicInput,
copyGeneratedActions,
);
const randomGenerateBtn = generationActions.querySelector('#generate-random-solana');
const fromPrivateGenerateBtn = generationActions.querySelector('#generate-from-private-solana');
const copyGeneratedPrivateBtn = copyGeneratedActions.querySelector('#copy-generated-private-solana');
const copyGeneratedPublicBtn = copyGeneratedActions.querySelector('#copy-generated-public-solana');
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
const validatePrivateInput = () => {
const value = String(privateInput.value || '').trim();
if (!value) {
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
return false;
}
if (!BASE58_RE.test(value)) {
privateState.textContent = 'Недопустимый формат: используйте только Base58.';
return false;
}
if (value.length > SOLANA_PRIVATE_BASE58_MAX_LEN) {
privateState.textContent = 'Слишком длинное значение.';
return false;
}
try {
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let num = 0n;
for (const c of value) {
num = num * 58n + BigInt(alphabet.indexOf(c));
}
let hex = num.toString(16);
if (hex.length % 2) hex = `0${hex}`;
const decoded = hex ? hex.match(/.{1,2}/g)?.map((h) => parseInt(h, 16)) || [] : [];
let leadingZeros = 0;
while (leadingZeros < value.length && value[leadingZeros] === '1') leadingZeros += 1;
const byteLen = leadingZeros + decoded.length;
if (byteLen < 32) {
privateState.textContent = 'Слишком короткое значение: нужно 32 байта.';
return false;
}
if (byteLen > 32) {
privateState.textContent = 'Слишком длинное значение: нужно ровно 32 байта.';
return false;
}
} catch {
privateState.textContent = 'Ошибка декодирования Base58.';
return false;
}
privateState.textContent = 'Подходит';
return true;
};
privateInput.addEventListener('input', () => {
validatePrivateInput();
});
const setGenerationDisabled = (disabled) => {
randomGenerateBtn.disabled = disabled;
fromPrivateGenerateBtn.disabled = disabled;
copyGeneratedPrivateBtn.disabled = disabled;
copyGeneratedPublicBtn.disabled = disabled;
};
randomGenerateBtn.addEventListener('click', async () => {
setGenerationDisabled(true);
try {
const generated = await createRandomSolanaWallet();
if (modeToken !== activeModeToken) return;
generatedPrivateInput.value = generated.privateKey32Base58;
generatedPublicInput.value = generated.address;
privateState.textContent = 'Случайный кошелёк создан.';
setStatus('Случайный кошелёк Solana успешно сгенерирован.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка генерации случайного кошелька: ${error?.message || 'unknown'}`);
} finally {
setGenerationDisabled(false);
}
});
fromPrivateGenerateBtn.addEventListener('click', async () => {
if (!validatePrivateInput()) {
setStatus('Исправьте приватный ключ перед генерацией.');
return;
}
setGenerationDisabled(true);
try {
const generated = await createSolanaWalletFromPrivateBase58(privateInput.value);
if (modeToken !== activeModeToken) return;
generatedPrivateInput.value = generated.privateKey32Base58;
generatedPublicInput.value = generated.address;
privateState.textContent = 'Подходит';
setStatus('Публичный ключ сгенерирован из введённого приватного ключа.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка генерации из приватного ключа: ${error?.message || 'unknown'}`);
} finally {
setGenerationDisabled(false);
}
});
copyGeneratedPrivateBtn.addEventListener('click', async () => {
const value = String(generatedPrivateInput.value || '').trim();
if (!value) {
setStatus('Сначала сгенерируйте приватный ключ.');
return;
}
try {
await navigator.clipboard.writeText(value);
setStatus('Приватный ключ скопирован.');
} catch {
setStatus('Не удалось скопировать приватный ключ в этом браузере.');
}
});
copyGeneratedPublicBtn.addEventListener('click', async () => {
const value = String(generatedPublicInput.value || '').trim();
if (!value) {
setStatus('Сначала сгенерируйте публичный ключ.');
return;
}
try {
await navigator.clipboard.writeText(value);
setStatus('Публичный ключ скопирован.');
} catch {
setStatus('Не удалось скопировать публичный ключ в этом браузере.');
}
});
const refreshBalance = async () => {
if (!walletAddress) {
setStatus('Кошелёк не инициализирован.');
return;
}
refreshBtn.disabled = true;
try {
const balance = await getBalanceSol({
endpoint: state.entrySettings.solanaServer,
address: walletAddress,
});
if (modeToken !== activeModeToken) return;
balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
endpointLabel.textContent = `RPC: ${balance.endpoint}`;
setStatus('Баланс обновлён.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
}
};
copyBtn.addEventListener('click', async () => {
if (!walletAddress) return;
try {
await navigator.clipboard.writeText(walletAddress);
setStatus('Адрес скопирован в буфер обмена');
} catch {
setStatus('Не удалось скопировать адрес в этом браузере');
}
});
refreshBtn.addEventListener('click', () => {
void refreshBalance();
});
sendBtn.addEventListener('click', async () => {
if (!walletCtx?.keypair) {
setStatus('Перевод недоступен: client.key не загружен.');
return;
}
const toAddress = window.prompt('Введите адрес получателя (Solana):', '');
if (!toAddress) return;
const amountRaw = window.prompt('Введите сумму SOL для перевода:', '0.01');
if (!amountRaw) return;
sendBtn.disabled = true;
try {
const tx = await transferSol({
endpoint: state.entrySettings.solanaServer,
fromKeypair: walletCtx.keypair,
toAddress,
amountSol: Number(String(amountRaw || '').replace(',', '.')),
});
if (modeToken !== activeModeToken) return;
setStatus(`Перевод отправлен. Signature: ${tx.signature}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`);
} finally {
sendBtn.disabled = false;
}
});
topupBtn.addEventListener('click', async () => {
if (!walletAddress) {
setStatus('Кошелёк не инициализирован.');
return;
}
window.location.assign(getTopupSiteUrl(walletAddress));
});
content.append(backBtn, card, actions, generatedCard);
setStatus('Инициализация client.key...');
try {
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
if (modeToken !== activeModeToken) return;
walletAddress = walletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'client.key недоступен';
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
}
}
async function renderArweaveWallet() {
const modeToken = ++activeModeToken;
content.innerHTML = '';
let walletAddress = '';
const backBtn = createModeBackButton(renderWalletChoice);
const card = document.createElement('div');
card.className = 'card stack';
const balanceWrap = document.createElement('div');
const balanceLabel = document.createElement('p');
balanceLabel.className = 'meta-muted';
balanceLabel.textContent = 'Баланс (Arweave)';
const balanceValue = document.createElement('h2');
balanceValue.style.fontSize = '30px';
balanceValue.textContent = '— AR';
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
const gatewayLabel = document.createElement('p');
gatewayLabel.className = 'meta-muted';
gatewayLabel.textContent = `Gateway: ${state.entrySettings.arweaveServer}`;
balanceWrap.append(balanceLabel, balanceValue, updatedLabel, gatewayLabel);
const addressCard = document.createElement('div');
addressCard.className = 'card';
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес Arweave (SAWD-v1)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
const helpCard = document.createElement('details');
helpCard.className = 'card';
helpCard.style.padding = '10px';
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;
card.append(balanceWrap, addressCard, helpCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<div class="row">
<button class="text-btn" id="copy-address">Копировать адрес</button>
<button class="ghost-btn" id="refresh-balance">Обновить баланс</button>
</div>
<div class="row">
<button class="primary-btn" id="send-ar" style="width:100%;">Перевести</button>
<button class="primary-btn" id="topup-ar" style="width:100%;">Пополнить</button>
</div>
`;
const copyBtn = actions.querySelector('#copy-address');
const refreshBtn = actions.querySelector('#refresh-balance');
const sendBtn = actions.querySelector('#send-ar');
const topupBtn = actions.querySelector('#topup-ar');
const refreshBalance = async () => {
if (!walletAddress) {
setStatus('Arweave-кошелёк не инициализирован.');
return;
}
refreshBtn.disabled = true;
try {
const balance = await getArweaveBalance({
gateway: state.entrySettings.arweaveServer,
address: walletAddress,
});
if (modeToken !== activeModeToken) return;
balanceValue.textContent = `${formatAr(balance.ar, 6)} AR`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
gatewayLabel.textContent = `Gateway: ${balance.gateway}`;
setStatus('Баланс обновлён.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
}
};
copyBtn.addEventListener('click', async () => {
if (!walletAddress) return;
try {
await navigator.clipboard.writeText(walletAddress);
setStatus('Адрес скопирован');
} catch {
setStatus('Не удалось скопировать адрес в этом браузере');
}
});
refreshBtn.addEventListener('click', () => {
void refreshBalance();
});
sendBtn.addEventListener('click', async () => {
if (!arweaveWalletCtx?.jwk) {
setStatus('Перевод недоступен: Arweave-кошелёк не инициализирован.');
return;
}
const toAddress = window.prompt('Введите адрес получателя (Arweave):', '');
if (!toAddress) return;
const amountRaw = window.prompt('Введите сумму AR для перевода:', '0.01');
if (!amountRaw) return;
sendBtn.disabled = true;
try {
const tx = await transferAr({
gateway: state.entrySettings.arweaveServer,
jwk: arweaveWalletCtx.jwk,
toAddress,
amountAr: amountRaw,
});
if (modeToken !== activeModeToken) return;
setStatus(`Перевод отправлен. Transaction ID: ${tx.id}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`);
} finally {
sendBtn.disabled = false;
}
});
topupBtn.addEventListener('click', () => {
window.open(getArweaveTopupSiteUrl(), '_blank', 'noopener,noreferrer');
setStatus('Открыта страница пополнения.');
});
content.append(backBtn, card, actions);
setStatus('Генерация Arweave-кошелька...');
try {
let wasFirstTimeGeneration = false;
arweaveWalletCtx = await getArweaveWalletFromStoredClientKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
wasFirstTimeGeneration = true;
setStatus('Подождите — ваш Arweave-ключ вычисляется из client key. Это происходит только один раз, потом будет мгновенно.');
return;
}
setStatus(text);
},
});
if (modeToken !== activeModeToken) return;
if (wasFirstTimeGeneration) setStatus('');
walletAddress = arweaveWalletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'client.key недоступен';
clearArweaveSecretsInMemory();
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
}
}
renderWalletChoice();
return screen;
}