SHiNE-server/shine-UI/js/pages/wallet-view.js

453 lines
17 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 { state } from '../state.js';
import {
formatSol,
getBalanceSol,
getTopupSiteUrl,
getWalletFromStoredDeviceKey,
requestAirdropSol,
transferSol,
} from '../services/solana-wallet-service.js';
import {
formatAr,
getArweaveBalance,
getArweaveTopupSiteUrl,
getArweaveWalletFromStoredDeviceKey,
transferAr,
} from '../services/arweave-wallet-service.js';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
function nowRu() {
return new Date().toLocaleString('ru-RU');
}
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();
});
card.append(solanaBtn, arweaveBtn);
content.append(card);
setStatus('Выберите тип кошелька.');
}
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;">Публичный адрес (wallet.key = device.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 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);
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 = `
<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 берёт ваш локальный device.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 {
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;
}