import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import {
createRandomSolanaWallet,
createSolanaWalletFromPrivateBase58,
formatSol,
getBalanceSol,
getTopupSiteUrl,
getWalletFromStoredDeviceKey,
requestAirdropSol,
transferSol,
} from '../services/solana-wallet-service.js';
import {
formatAr,
getArweaveBalance,
getArweaveTopupSiteUrl,
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=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 = `
Кошелёк
Выберите режим кошелька.
`;
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 = `
Обновить
Закрепить в Solana
Увеличить лимит
`;
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 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;
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,
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();
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 = `
Публичный адрес (wallet.key = device.key)
—
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
card.append(balanceWrap, addressCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
Копировать адрес
Обновить баланс
Перевести
Пополнить
`;
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 = `
Создание нового кошелька Solana
Введите приватный ключ Base58 (32 байта) или сгенерируйте случайный.
`;
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 = `
Сгенерировать случайный кошелёк
Сгенерировать из приватного ключа
`;
const copyGeneratedActions = document.createElement('div');
copyGeneratedActions.className = 'row';
copyGeneratedActions.innerHTML = `
Копировать приватный
Копировать публичный
`;
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('Перевод недоступен: wallet.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;
}
const openSite = window.confirm(
'Кнопка будет переводить на отдельный сайт пополнения.\n\nНажмите OK, чтобы открыть сайт.\nНажмите Отмена, чтобы выполнить тестовое пополнение (airdrop 1 SOL на DevNet).',
);
if (openSite) {
window.open(getTopupSiteUrl(walletAddress), '_blank', 'noopener,noreferrer');
setStatus('Открыт сайт пополнения. Пока также доступно тестовое пополнение.');
return;
}
topupBtn.disabled = true;
try {
const drop = await requestAirdropSol({
endpoint: state.entrySettings.solanaServer,
address: walletAddress,
amountSol: 1,
});
if (modeToken !== activeModeToken) return;
setStatus(`Тестовое пополнение выполнено (airdrop 1 SOL). Signature: ${drop.signature}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка тестового пополнения: ${error?.message || 'unknown'}`);
} finally {
topupBtn.disabled = false;
}
});
content.append(backBtn, card, actions, generatedCard);
setStatus('Инициализация wallet.key...');
try {
walletCtx = await getWalletFromStoredDeviceKey(sessionArgsOrThrow());
if (modeToken !== activeModeToken) return;
walletAddress = walletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'wallet.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 = `
Публичный адрес Arweave (SAWD-v1)
—
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
const helpCard = document.createElement('details');
helpCard.className = 'card';
helpCard.style.padding = '10px';
helpCard.innerHTML = `
Как получен этот адрес?
SHiNE берёт ваш локальный device.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
`;
card.append(balanceWrap, addressCard, helpCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
Копировать адрес
Обновить баланс
Перевести
Пополнить
`;
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 {
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
setStatus('Сейчас мы впервые получаем Arweave-кошелёк из вашего приватного device key. Это может занять немного времени. После этого кошелёк будет храниться только в зашифрованном контейнере этого устройства.');
return;
}
setStatus(text);
},
});
if (modeToken !== activeModeToken) return;
walletAddress = arweaveWalletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'wallet.key недоступен';
clearArweaveSecretsInMemory();
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
}
}
renderWalletChoice();
return screen;
}