931 lines
38 KiB
JavaScript
931 lines
38 KiB
JavaScript
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;
|
||
}
|