import { renderHeader } from '../components/header.js'; import { authService, state } from '../state.js'; import { 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 { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; import { calcLimitTopupPriceLamports, getLimitStepBytes, getShineBlockchainUsage, getShineUsersEconomyConfig, updateShineUserPdaOnSolana, } from '../services/shine-blockchain-wallet-service.js?v=202605300007'; export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' }; 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 }; } const SHINE_PAYMENTS_PROGRAM_ID = 'c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW'; const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE'; const SHINE_PAYMENTS_SEEDS = { config: 'shine_payments_config', coef: 'shine_payments_coef_limit', queues: 'shine_payments_queues', inflow: 'shine_payments_inflow_vault', q1: 'shine_payments_q1_ticket', q2: 'shine_payments_q2_ticket', q3: 'shine_payments_q3_ticket', }; const COEF_SCALE_PPM = 1_000_000n; const LAMPORTS_PER_SOL = 1_000_000_000n; const SUPPORT_TICKET_HELP_TEXT = [ 'Билет привязан к очереди 1 и к адресу получателя, который вы укажете при покупке.', 'Оплата считается в USD, а списание идет в SOL по курсу USDT/SOL, который проверяется в момент транзакции.', 'Если курс уезжает слишком далеко, покупка отклоняется. Для интерфейса заложен допуск 3%.', 'Когда текущий лимит очереди заполнится, откроется новый лимит с более низким коэффициентом.', 'Номер билета нужно сохранить отдельно: именно по нему удобно отслеживать, когда он дойдет до выплаты.', ].join('\n'); function utf8Bytes(text) { return new TextEncoder().encode(String(text || '')); } function concatBytes(...parts) { const total = parts.reduce((sum, part) => sum + part.length, 0); const out = new Uint8Array(total); let offset = 0; parts.forEach((part) => { out.set(part, offset); offset += part.length; }); return out; } const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; function encodeBase58(bytes) { const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []); if (source.length === 0) return ''; const digits = [0]; for (let i = 0; i < source.length; i += 1) { let carry = source[i]; for (let j = 0; j < digits.length; j += 1) { const value = (digits[j] << 8) + carry; digits[j] = value % 58; carry = Math.floor(value / 58); } while (carry > 0) { digits.push(carry % 58); carry = Math.floor(carry / 58); } } for (let i = 0; i < source.length && source[i] === 0; i += 1) { digits.push(0); } return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join(''); } function u64ToBytes(value) { const out = new Uint8Array(8); let current = BigInt(value || 0); for (let i = 0; i < 8; i += 1) { out[i] = Number(current & 0xffn); current >>= 8n; } return out; } function readU64(data, offset) { let value = 0n; for (let i = 0; i < 8; i += 1) { value |= BigInt(data[offset + i]) << (8n * BigInt(i)); } return value; } function readI64(data, offset) { let value = readU64(data, offset); if (value > 0x7fffffffffffffffn) { value -= 0x10000000000000000n; } return value; } function readI32(data, offset) { let value = Number(readU64(data, offset) & 0xffffffffn); if (value > 0x7fffffff) value -= 0x100000000; return value; } function trimZeros(value) { return String(value || '') .replace(/(\.\d*?[1-9])0+$/u, '$1') .replace(/\.0+$/u, '') .replace(/\.$/u, ''); } function formatUsdCentsText(cents) { const value = Number(cents || 0n) / 100; return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } function formatPpmCoefText(coefPpm) { const value = Number(coefPpm || 0n) / Number(COEF_SCALE_PPM); return `${trimZeros(value.toFixed(6))}x`; } function formatLamportsSolText(lamports, digits = 9) { const value = Number(lamports || 0n) / Number(LAMPORTS_PER_SOL); return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: digits }); } function formatPythSolUsdText(pyth) { const value = Number(pyth?.priceNum || 0n) / Number(pyth?.priceDen || 1n) / 100; return trimZeros(value.toFixed(6)); } function formatQueuePrefix(queueId) { if (queueId === 2) return 'Q2'; if (queueId === 3) return 'Q3'; return 'Q1'; } function parsePaymentsCoef(data) { let offset = 0; const version = data[offset]; offset += 1; const coefPpm = readU64(data, offset); offset += 8; const limitUsdCents = readU64(data, offset); offset += 8; const callRewardLamports = readU64(data, offset); return { version, coefPpm, limitUsdCents, callRewardLamports }; } function parsePaymentsQueues(data) { let offset = 0; const version = data[offset]; offset += 1; const q1TicketsTotal = readU64(data, offset); offset += 8; const q1TicketsPaid = readU64(data, offset); offset += 8; const q1SumTotalUsdCents = readU64(data, offset); offset += 8; const q1SumPaidUsdCents = readU64(data, offset); offset += 8; const q2TicketsTotal = readU64(data, offset); offset += 8; const q2TicketsPaid = readU64(data, offset); offset += 8; const q2SumTotalUsdCents = readU64(data, offset); offset += 8; const q2SumPaidUsdCents = readU64(data, offset); offset += 8; const q3TicketsTotal = readU64(data, offset); offset += 8; const q3TicketsPaid = readU64(data, offset); offset += 8; const q3SumTotalUsdCents = readU64(data, offset); offset += 8; const q3SumPaidUsdCents = readU64(data, offset); return { version, q1TicketsTotal, q1TicketsPaid, q1SumTotalUsdCents, q1SumPaidUsdCents, q2TicketsTotal, q2TicketsPaid, q2SumTotalUsdCents, q2SumPaidUsdCents, q3TicketsTotal, q3TicketsPaid, q3SumTotalUsdCents, q3SumPaidUsdCents, }; } function parsePaymentsTicket(data) { let offset = 0; const version = data[offset]; offset += 1; const queueId = data[offset]; offset += 1; const index = readU64(data, offset); offset += 8; const isPaid = Boolean(data[offset]); offset += 1; const solana = window.solanaWeb3; const recipientWallet = new solana.PublicKey(data.slice(offset, offset + 32)).toBase58(); offset += 32; const payoutUsdCents = readU64(data, offset); offset += 8; const debtBeforeUsdCents = readU64(data, offset); return { version, queueId, index, isPaid, recipientWallet, payoutUsdCents, debtBeforeUsdCents, }; } function parseSupportTicketInput(rawValue) { const clean = String(rawValue || '').trim(); if (!clean) throw new Error('Введите номер билета.'); if (/^\d+$/.test(clean)) { return { queueId: 1, index: BigInt(clean) }; } const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/); if (!match) { throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.'); } return { queueId: Number(match[1]), index: BigInt(match[2]), }; } async function deriveSupportRandomWallet(extraText) { const solana = await loadSolanaWeb3(); if (!window.crypto?.getRandomValues || !window.crypto?.subtle) { throw new Error('Этот браузер не поддерживает безопасную генерацию ключей.'); } const entropy = new Uint8Array(32); window.crypto.getRandomValues(entropy); const payload = concatBytes( entropy, utf8Bytes(extraText || ''), utf8Bytes(new Date().toISOString()), utf8Bytes(String(Date.now())), utf8Bytes(String(performance?.now?.() || 0)), ); const seed = new Uint8Array(await window.crypto.subtle.digest('SHA-256', payload)); const keypair = solana.Keypair.fromSeed(seed); return { address: keypair.publicKey.toBase58(), privateKey32Base58: encodeBase58(seed), keypair, generatedAt: new Date().toLocaleString('ru-RU'), }; } async function loadSupportPaymentsCore(endpoint) { const solana = await loadSolanaWeb3(); const rpc = String(endpoint || '').trim(); const connection = new solana.Connection(rpc, 'confirmed'); const programId = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID); const oracleAccount = new solana.PublicKey(SHINE_PAYMENTS_ORACLE_ACCOUNT); const [configPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.config)], programId); const [coefPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.coef)], programId); const [queuesPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.queues)], programId); const [inflowPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.inflow)], programId); const [oracleAi, configAi, coefAi, queuesAi] = await Promise.all([ connection.getAccountInfo(oracleAccount, 'confirmed'), connection.getAccountInfo(configPda, 'confirmed'), connection.getAccountInfo(coefPda, 'confirmed'), connection.getAccountInfo(queuesPda, 'confirmed'), ]); if (!oracleAi) throw new Error('Не найден аккаунт оракула SOL/USD.'); if (!configAi || !coefAi || !queuesAi) throw new Error('PDA программы оплаты ещё не инициализированы.'); const parsePrice = (data) => { const price = readI64(data, 73); const exponent = readI32(data, 89); const publishTime = readI64(data, 93); if (price <= 0n) throw new Error('Оракул вернул некорректную цену.'); let num = price * 100n; let den = 1n; if (exponent >= 0) { num *= 10n ** BigInt(exponent); } else { den *= 10n ** BigInt(-exponent); } return { priceNum: num, priceDen: den, publishTime }; }; let configOffset = 0; const configVersion = configAi.data[configOffset]; configOffset += 1; const daoWallet = new solana.PublicKey(configAi.data.slice(configOffset, configOffset + 32)).toBase58(); configOffset += 32; const inflowVault = new solana.PublicKey(configAi.data.slice(configOffset, configOffset + 32)).toBase58(); return { connection, programId, oracleAccount, configPda, coefPda, queuesPda, inflowPda, config: { version: configVersion, daoWallet, inflowVault }, coef: parsePaymentsCoef(coefAi.data), queues: parsePaymentsQueues(queuesAi.data), pyth: parsePrice(oracleAi.data), }; } function queueStateView(queues, queueId) { if (queueId === 2) { return { ticketsTotal: queues.q2TicketsTotal, ticketsPaid: queues.q2TicketsPaid, sumTotalUsdCents: queues.q2SumTotalUsdCents, sumPaidUsdCents: queues.q2SumPaidUsdCents, }; } if (queueId === 3) { return { ticketsTotal: queues.q3TicketsTotal, ticketsPaid: queues.q3TicketsPaid, sumTotalUsdCents: queues.q3SumTotalUsdCents, sumPaidUsdCents: queues.q3SumPaidUsdCents, }; } return { ticketsTotal: queues.q1TicketsTotal, ticketsPaid: queues.q1TicketsPaid, sumTotalUsdCents: queues.q1SumTotalUsdCents, sumPaidUsdCents: queues.q1SumPaidUsdCents, }; } function queueSeedFor(queueId) { if (queueId === 2) return SHINE_PAYMENTS_SEEDS.q2; if (queueId === 3) return SHINE_PAYMENTS_SEEDS.q3; return SHINE_PAYMENTS_SEEDS.q1; } function ticketPdaFor(programId, queueId, index) { const solana = window.solanaWeb3; return solana.PublicKey.findProgramAddressSync( [utf8Bytes(queueSeedFor(queueId)), u64ToBytes(index)], programId, ); } 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 styleSupportInputField(field) { if (!field) return; field.style.color = '#111111'; field.style.webkitTextFillColor = '#111111'; field.style.caretColor = '#111111'; field.style.backgroundColor = '#ffffff'; } function renderSupportHub() { activeModeToken += 1; clearArweaveSecretsInMemory(); content.innerHTML = ''; const backBtn = createModeBackButton(renderWalletChoice); const intro = document.createElement('div'); intro.className = 'card stack'; intro.innerHTML = `

Поддержать проект Сияние

Здесь можно купить билет, посмотреть очередь и увидеть, как устроена покупка. Оплата идет в SOL, а сумма считается по курсу USD/USDT на момент транзакции. Номер билета и секретный ключ кошелька нужно сохранить отдельно.

`; const actions = document.createElement('div'); actions.className = 'stack'; actions.innerHTML = ` `; actions.querySelector('#support-buy')?.addEventListener('click', () => { void renderSupportBuyIntro(); }); actions.querySelector('#support-queue')?.addEventListener('click', () => { void renderSupportQueue(); }); actions.querySelector('#support-keygen')?.addEventListener('click', () => { void renderSupportKeygen(); }); content.append(backBtn, intro, actions); setStatus('Выберите действие в разделе поддержки.'); } async function renderSupportHelp(backTarget = renderSupportBuyForm) { const modeToken = ++activeModeToken; clearArweaveSecretsInMemory(); content.innerHTML = ''; const backBtn = createModeBackButton(backTarget); const card = document.createElement('div'); card.className = 'card stack'; card.innerHTML = `

Справка по покупке билета

${SUPPORT_TICKET_HELP_TEXT}

После покупки билет остается в очереди 1. Позже можно открыть экран просмотра и проверить, сколько билетов и сумм уже прошло перед ним.

`; content.append(backBtn, card); if (modeToken === activeModeToken) setStatus('Открыта подробная справка.'); } async function renderSupportKeygen() { const modeToken = ++activeModeToken; clearArweaveSecretsInMemory(); content.innerHTML = ''; const backBtn = createModeBackButton(renderSupportHub); const card = document.createElement('div'); card.className = 'card stack'; card.innerHTML = `

Сгенерировать новую пару ключей

Генерация использует безопасный рандом браузера, а ваш дополнительный текст и текущее время добавляются как примесь. Вводить текст необязательно: даже без него ключи остаются случайными.

`; const saltLabel = document.createElement('label'); saltLabel.className = 'meta-muted'; saltLabel.setAttribute('for', 'support-key-salt'); saltLabel.textContent = 'Дополнительная соль (необязательно)'; const saltInput = document.createElement('textarea'); saltInput.id = 'support-key-salt'; saltInput.rows = 3; saltInput.placeholder = 'Можно оставить пустым или добавить любой текст как дополнительную примесь'; saltInput.spellcheck = false; styleSupportInputField(saltInput); const timeLabel = document.createElement('p'); timeLabel.className = 'meta-muted'; timeLabel.textContent = 'Время генерации появится после нажатия кнопки.'; const generatedPublicLabel = document.createElement('label'); generatedPublicLabel.className = 'meta-muted'; generatedPublicLabel.setAttribute('for', 'support-generated-public'); generatedPublicLabel.textContent = 'Публичный ключ'; const generatedPublicInput = document.createElement('input'); generatedPublicInput.id = 'support-generated-public'; generatedPublicInput.type = 'text'; generatedPublicInput.readOnly = true; generatedPublicInput.placeholder = 'Появится после генерации'; styleSupportInputField(generatedPublicInput); const generatedSecretLabel = document.createElement('label'); generatedSecretLabel.className = 'meta-muted'; generatedSecretLabel.setAttribute('for', 'support-generated-secret'); generatedSecretLabel.textContent = 'Секретный ключ (Base58, показывается один раз)'; const generatedSecretInput = document.createElement('textarea'); generatedSecretInput.id = 'support-generated-secret'; generatedSecretInput.rows = 4; generatedSecretInput.readOnly = true; generatedSecretInput.placeholder = 'Появится после генерации'; generatedSecretInput.spellcheck = false; styleSupportInputField(generatedSecretInput); const generatedAddressNote = document.createElement('p'); generatedAddressNote.className = 'meta-muted'; generatedAddressNote.style.margin = '0'; generatedAddressNote.textContent = 'Secret key не сохраняется на сервере и не пишется в localStorage.'; const actions = document.createElement('div'); actions.className = 'stack'; actions.innerHTML = `
`; const generateBtn = actions.querySelector('#support-generate'); const copyPublicBtn = actions.querySelector('#support-copy-public'); const copySecretBtn = actions.querySelector('#support-copy-secret'); const downloadBtn = actions.querySelector('#support-download'); let generatedPair = null; const clearGenerated = () => { generatedPair = null; generatedPublicInput.value = ''; generatedSecretInput.value = ''; timeLabel.textContent = 'Время генерации появится после нажатия кнопки.'; }; clearGenerated(); generateBtn?.addEventListener('click', async () => { generateBtn.disabled = true; try { generatedPair = await deriveSupportRandomWallet(saltInput.value); if (modeToken !== activeModeToken) return; generatedPublicInput.value = generatedPair.address; generatedSecretInput.value = generatedPair.privateKey32Base58; timeLabel.textContent = `Сгенерировано: ${generatedPair.generatedAt}. Использован безопасный рандом браузера, текст и время добавлены как примесь.`; setStatus('Новая пара ключей сгенерирована. Секретный ключ показан на экране один раз.'); } catch (error) { if (modeToken !== activeModeToken) return; setStatus(`Не удалось сгенерировать пару ключей: ${error?.message || 'unknown'}`); } finally { generateBtn.disabled = false; } }); copyPublicBtn?.addEventListener('click', async () => { const value = String(generatedPublicInput.value || '').trim(); if (!value) { setStatus('Сначала сгенерируйте пару ключей.'); return; } try { await navigator.clipboard.writeText(value); setStatus('Public key скопирован.'); } catch { setStatus('Не удалось скопировать public key.'); } }); copySecretBtn?.addEventListener('click', async () => { const value = String(generatedSecretInput.value || '').trim(); if (!value) { setStatus('Сначала сгенерируйте пару ключей.'); return; } try { await navigator.clipboard.writeText(value); setStatus('Secret key скопирован.'); } catch { setStatus('Не удалось скопировать secret key.'); } }); downloadBtn?.addEventListener('click', () => { const publicKey = String(generatedPublicInput.value || '').trim(); const secretKey = String(generatedSecretInput.value || '').trim(); if (!publicKey || !secretKey) { setStatus('Сначала сгенерируйте пару ключей.'); return; } const payload = [ `Публичный ключ: ${publicKey}`, `Секретный ключ: ${secretKey}`, `Время: ${timeLabel.textContent || ''}`, ].join('\n'); const blob = new Blob([payload], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `shine-keypair-${Date.now()}.txt`; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 0); setStatus('Файл с ключами скачан.'); }); content.append( backBtn, card, saltLabel, saltInput, timeLabel, generatedPublicLabel, generatedPublicInput, generatedSecretLabel, generatedSecretInput, generatedAddressNote, actions, ); setStatus('Генератор ключей готов. Дополнительная соль необязательна.'); } async function renderSupportQueue() { const modeToken = ++activeModeToken; clearArweaveSecretsInMemory(); content.innerHTML = ''; const backBtn = createModeBackButton(renderSupportHub); const card = document.createElement('div'); card.className = 'card stack'; card.innerHTML = `

Посмотреть очередь билета

Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате 2-15 или 3 8. Экран покажет, сколько билетов перед ним уже стоит, сколько уже выплачено и кому принадлежит билет.

`; const inputLabel = document.createElement('label'); inputLabel.className = 'meta-muted'; inputLabel.setAttribute('for', 'support-ticket-query'); inputLabel.textContent = 'Номер билета'; const queryInput = document.createElement('input'); queryInput.id = 'support-ticket-query'; queryInput.type = 'text'; queryInput.placeholder = 'Например: 12, 2-5 или 3 8'; queryInput.autocomplete = 'off'; queryInput.spellcheck = false; styleSupportInputField(queryInput); const actions = document.createElement('div'); actions.className = 'row'; actions.innerHTML = ` `; const result = document.createElement('div'); result.className = 'card stack'; result.innerHTML = `

Введите номер билета и нажмите кнопку.

`; let lastCoreState = null; const renderTicketInfo = async () => { const raw = String(queryInput.value || '').trim(); if (!raw) { result.innerHTML = `

Введите номер билета.

`; return; } try { const parsed = parseSupportTicketInput(raw); const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer); lastCoreState = core; const solana = await loadSolanaWeb3(); const ticketPdaSeed = queueSeedFor(parsed.queueId); const ticketIndexBytes = u64ToBytes(parsed.index); const [ticketPda] = solana.PublicKey.findProgramAddressSync( [utf8Bytes(ticketPdaSeed), ticketIndexBytes], core.programId, ); const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed'); if (!ticketInfo) { result.innerHTML = `

Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.

`; return; } const ticket = parsePaymentsTicket(ticketInfo.data); const queueState = queueStateView(core.queues, parsed.queueId); const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n; const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore; const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n; result.innerHTML = `
Билет: ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
Получатель: ${ticket.recipientWallet}
До него в очереди: ${ticketsBefore.toString()} билетов
Из них уже выплачено: ${paidBefore.toString()} билетов
Ещё осталось до него: ${remainingBefore.toString()} билетов
Уже выплачено по сумме в очереди: ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD
Сумма этого билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
Накоплено до этого билета: ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него
`; setStatus(`Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`); } catch (error) { result.innerHTML = `

${String(error?.message || 'Не удалось загрузить билет')}

`; setStatus(`Не удалось посмотреть билет: ${error?.message || 'unknown'}`); } }; actions.querySelector('#support-ticket-load')?.addEventListener('click', () => { void renderTicketInfo(); }); actions.querySelector('#support-ticket-reset')?.addEventListener('click', () => { queryInput.value = ''; result.innerHTML = `

Введите номер билета и нажмите кнопку.

`; setStatus('Поле билета очищено.'); }); content.append(backBtn, card, inputLabel, queryInput, actions, result); setStatus('Просмотр билета готов. Введите номер в нужном формате.'); } async function renderSupportBuyIntro() { const modeToken = ++activeModeToken; clearArweaveSecretsInMemory(); content.innerHTML = ''; const backBtn = createModeBackButton(renderSupportHub); const introCard = document.createElement('div'); introCard.className = 'card stack'; introCard.innerHTML = `

Купить билет

Сначала показываем условия и текущий лимит. После этого можно перейти к покупке и ввести сумму в долларах. Оплата идет в SOL, а расчет строится по курсу USD/USDT на момент транзакции.

`; const stateCard = document.createElement('div'); stateCard.className = 'card stack'; stateCard.innerHTML = `

Загрузка условий...

`; const actions = document.createElement('div'); actions.className = 'stack'; actions.innerHTML = ` `; const nextBtn = actions.querySelector('#support-buy-next'); const helpBtn = actions.querySelector('#support-buy-help'); nextBtn.disabled = true; let currentCore = null; try { currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer); if (modeToken !== activeModeToken) return; const queue = queueStateView(currentCore.queues, 1); const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents ? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents : 0n; stateCard.innerHTML = `
Коэффициент: ${formatPpmCoefText(currentCore.coef.coefPpm)}
Лимит текущего коэффициента: ${formatUsdCentsText(currentCore.coef.limitUsdCents)} USD
Уже куплено в очереди 1: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
Осталось купить по текущему коэффициенту: ${formatUsdCentsText(remainingUsdCents)} USD
Сколько билетов уже в очереди: ${queue.ticketsTotal.toString()}
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
Курс: 1 SOL = ${formatPythSolUsdText(currentCore.pyth)} USD
После заполнения лимита будет новый лимит, но уже с более низким коэффициентом.
`; nextBtn.disabled = false; setStatus('Условия покупки загружены. Можно переходить к форме покупки.'); } catch (error) { if (modeToken !== activeModeToken) return; stateCard.innerHTML = `

${String(error?.message || 'Не удалось загрузить состояние')}

`; nextBtn.disabled = true; setStatus(`Не удалось загрузить условия покупки: ${error?.message || 'unknown'}`); } nextBtn?.addEventListener('click', () => { void renderSupportBuyForm(); }); helpBtn?.addEventListener('click', () => { void renderSupportHelp(renderSupportBuyIntro); }); content.append(backBtn, introCard, stateCard, actions); } async function renderSupportBuyForm() { const modeToken = ++activeModeToken; clearArweaveSecretsInMemory(); content.innerHTML = ''; const backBtn = createModeBackButton(renderSupportBuyIntro); const helpCard = document.createElement('div'); helpCard.className = 'card stack'; helpCard.innerHTML = `

Купить билет на сумму в долларах

Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата. Покупка подписывается вашим client key и отклоняется, если курс уходит дальше допустимого порога.

`; const stateCard = document.createElement('div'); stateCard.className = 'card stack'; stateCard.innerHTML = `

Текущее состояние покупки загружается...

`; const amountLabel = document.createElement('label'); amountLabel.className = 'meta-muted'; amountLabel.setAttribute('for', 'support-buy-amount'); amountLabel.textContent = 'Сумма билета в долларах'; const amountInput = document.createElement('input'); amountInput.id = 'support-buy-amount'; amountInput.type = 'text'; amountInput.value = '20'; amountInput.inputMode = 'decimal'; amountInput.autocomplete = 'off'; styleSupportInputField(amountInput); const recipientWrap = document.createElement('div'); recipientWrap.className = 'stack'; const recipientInput = document.createElement('input'); recipientInput.id = 'support-buy-recipient'; recipientInput.type = 'text'; recipientInput.placeholder = 'Можно оставить пустым'; recipientInput.autocomplete = 'off'; recipientInput.spellcheck = false; styleSupportInputField(recipientInput); const recipientLabel = document.createElement('label'); recipientLabel.className = 'meta-muted'; recipientLabel.setAttribute('for', 'support-buy-recipient'); recipientLabel.textContent = 'Адрес кошелька для билета и выплаты'; recipientWrap.append(recipientLabel, recipientInput); const sameWalletRow = document.createElement('label'); sameWalletRow.className = 'row'; sameWalletRow.style.gap = '10px'; sameWalletRow.style.alignItems = 'center'; sameWalletRow.innerHTML = ` На тот же кошелёк, с которого покупаем `; const quoteCard = document.createElement('div'); quoteCard.className = 'card stack'; quoteCard.innerHTML = `

Расчет появится после загрузки курса.

`; const purchaseResultCard = document.createElement('div'); purchaseResultCard.className = 'card stack'; purchaseResultCard.innerHTML = `

После покупки здесь появится номер билета и краткий итог.

`; const actions = document.createElement('div'); actions.className = 'stack'; actions.innerHTML = `
`; const buyBtn = actions.querySelector('#support-buy-submit'); const refreshBtn = actions.querySelector('#support-buy-refresh'); const helpBtn = actions.querySelector('#support-buy-help'); const sameWalletCheckbox = sameWalletRow.querySelector('#support-buy-same-wallet'); let walletCtx = null; let walletAddress = ''; let currentCore = null; const setRecipientFromWallet = () => { if (sameWalletCheckbox?.checked && walletAddress) { recipientInput.value = walletAddress; recipientInput.disabled = true; } else { recipientInput.disabled = false; } styleSupportInputField(recipientInput); }; const updateQuote = () => { if (!currentCore) return; const queue = queueStateView(currentCore.queues, 1); const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents ? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents : 0n; const currentCoef = formatPpmCoefText(currentCore.coef.coefPpm); const usdRaw = String(amountInput.value || '').trim().replace(',', '.'); let amountUsdCents = 0n; let amountSol = '—'; let maxPaySol = '—'; let canBuy = true; try { const asNumber = Number(usdRaw); if (!Number.isFinite(asNumber) || asNumber <= 0) throw new Error('bad amount'); amountUsdCents = BigInt(Math.round(asNumber * 100)); const payLamports = (amountUsdCents * LAMPORTS_PER_SOL * currentCore.pyth.priceDen + currentCore.pyth.priceNum - 1n) / currentCore.pyth.priceNum; const maxPayLamports = (payLamports * 103n + 99n) / 100n; amountSol = formatLamportsSolText(payLamports, 9); maxPaySol = formatLamportsSolText(maxPayLamports, 9); if (amountUsdCents > remainingUsdCents) canBuy = false; } catch { canBuy = false; } quoteCard.innerHTML = `
Коэффициент сейчас: ${currentCoef}
В очереди перед вами уже куплено: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
Осталось до лимита текущего коэффициента: ${formatUsdCentsText(remainingUsdCents)} USD
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
Примерная цена: ${amountSol} SOL
Максимум при допуске 3%: ${maxPaySol} SOL
${canBuy ? 'Покупка укладывается в текущий лимит.' : 'Сумма сейчас не укладывается в текущий лимит или введена некорректно.'}
Если текущий лимит заполнится, откроется новый лимит с более низким коэффициентом. Покупка идет в SOL, но считается по курсу USD/USDT на момент транзакции.
`; const hasRecipient = sameWalletCheckbox?.checked ? Boolean(walletAddress) : Boolean(String(recipientInput.value || '').trim()); buyBtn.disabled = !canBuy || !hasRecipient; }; const refreshCore = async () => { refreshBtn.disabled = true; try { currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer); if (modeToken !== activeModeToken) return; const queue = queueStateView(currentCore.queues, 1); const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents ? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents : 0n; stateCard.innerHTML = `
Коэффициент: ${formatPpmCoefText(currentCore.coef.coefPpm)}
Лимит текущего коэффициента: ${formatUsdCentsText(currentCore.coef.limitUsdCents)} USD
Уже куплено в очереди 1: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
Осталось купить по текущему коэффициенту: ${formatUsdCentsText(remainingUsdCents)} USD
Сколько билетов уже в очереди: ${queue.ticketsTotal.toString()}
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
Курс: 1 SOL = ${formatPythSolUsdText(currentCore.pyth)} USD
После заполнения лимита будет новый лимит, но уже с более низким коэффициентом.
`; setRecipientFromWallet(); updateQuote(); setStatus('Условия покупки обновлены.'); } catch (error) { if (modeToken !== activeModeToken) return; stateCard.innerHTML = `

${String(error?.message || 'Не удалось загрузить состояние')}

`; quoteCard.innerHTML = `

${String(error?.message || 'Не удалось рассчитать цену')}

`; setStatus(`Не удалось загрузить условия покупки: ${error?.message || 'unknown'}`); } finally { refreshBtn.disabled = false; } }; amountInput.addEventListener('input', updateQuote); recipientInput.addEventListener('input', updateQuote); sameWalletCheckbox?.addEventListener('change', () => { setRecipientFromWallet(); updateQuote(); }); helpBtn?.addEventListener('click', () => { void renderSupportHelp(renderSupportBuyForm); }); refreshBtn?.addEventListener('click', () => { void refreshCore(); }); buyBtn?.addEventListener('click', async () => { buyBtn.disabled = true; try { if (!currentCore) { currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer); } if (!walletCtx) { walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow()); walletAddress = walletCtx.address; } setRecipientFromWallet(); const queue = queueStateView(currentCore.queues, 1); const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents ? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents : 0n; const amountUsd = String(amountInput.value || '').trim().replace(',', '.'); const amountUsdNumber = Number(amountUsd); if (!Number.isFinite(amountUsdNumber) || amountUsdNumber <= 0) { throw new Error('Введите корректную сумму в долларах.'); } const amountUsdCents = BigInt(Math.round(amountUsdNumber * 100)); if (amountUsdCents > remainingUsdCents) { throw new Error('Сумма превышает остаток текущего лимита. Уменьшите сумму или дождитесь нового лимита.'); } const recipientWallet = String(recipientInput.value || '').trim() || walletAddress; if (!recipientWallet) throw new Error('Не указан кошелёк получателя.'); const solana = await loadSolanaWeb3(); const recipientPubkey = new solana.PublicKey(recipientWallet); const payLamports = (amountUsdCents * LAMPORTS_PER_SOL * currentCore.pyth.priceDen + currentCore.pyth.priceNum - 1n) / currentCore.pyth.priceNum; const maxPayLamports = (payLamports * 103n + 99n) / 100n; const nextIndex = queue.ticketsTotal + 1n; const [ticketPda] = solana.PublicKey.findProgramAddressSync( [utf8Bytes(SHINE_PAYMENTS_SEEDS.q1), u64ToBytes(nextIndex)], currentCore.programId, ); const data = concatBytes( new Uint8Array([5]), u64ToBytes(amountUsdCents), u64ToBytes(maxPayLamports), recipientPubkey.toBytes(), ); const ix = new solana.TransactionInstruction({ programId: currentCore.programId, keys: [ { pubkey: walletCtx.keypair.publicKey, isSigner: true, isWritable: true }, { pubkey: currentCore.configPda, isSigner: false, isWritable: true }, { pubkey: currentCore.coefPda, isSigner: false, isWritable: true }, { pubkey: currentCore.queuesPda, isSigner: false, isWritable: true }, { pubkey: ticketPda, isSigner: false, isWritable: true }, { pubkey: new solana.PublicKey(currentCore.config.daoWallet), isSigner: false, isWritable: true }, { pubkey: currentCore.oracleAccount, isSigner: false, isWritable: false }, { pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false }, ], data, }); const connection = new solana.Connection(String(state.entrySettings.solanaServer || '').trim(), 'confirmed'); const tx = new solana.Transaction().add(ix); tx.feePayer = walletCtx.keypair.publicKey; const bh = await connection.getLatestBlockhash('confirmed'); tx.recentBlockhash = bh.blockhash; tx.partialSign(walletCtx.keypair); const signature = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false }); await connection.confirmTransaction( { signature, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, 'confirmed', ); const ticketInfo = await connection.getAccountInfo(ticketPda, 'confirmed'); const ticket = ticketInfo?.data ? parsePaymentsTicket(ticketInfo.data) : { index: nextIndex, queueId: 1, recipientWallet, payoutUsdCents: amountUsdCents }; if (modeToken !== activeModeToken) return; setStatus(`Билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()} куплен. Сохраните номер билета и secret key используемого кошелька.`); purchaseResultCard.innerHTML = `
Покупка завершена: билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()}
Получатель: ${ticket.recipientWallet}
Сумма билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
До него было в очереди: ${ticket.index > 0n ? (ticket.index - 1n).toString() : '0'} билетов
Транзакция: ${signature}
`; await refreshCore(); } catch (error) { if (modeToken !== activeModeToken) return; setStatus(`Не удалось купить билет: ${error?.message || 'unknown'}`); } finally { buyBtn.disabled = false; } }); content.append( backBtn, helpCard, stateCard, amountLabel, amountInput, recipientWrap, sameWalletRow, quoteCard, purchaseResultCard, actions, ); if (!walletCtx) { try { walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow()); walletAddress = walletCtx.address; if (modeToken !== activeModeToken) return; setRecipientFromWallet(); } catch (error) { setStatus(`Не удалось загрузить client.key: ${error?.message || 'unknown'}`); } } await refreshCore(); } 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(); }); const supportBtn = document.createElement('button'); supportBtn.className = 'primary-btn'; supportBtn.style.width = '100%'; supportBtn.textContent = 'Поддержать проект Сияние'; supportBtn.addEventListener('click', () => { void renderSupportHub(); }); card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn); 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 = ` `; 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 = `

Публичный адрес (client.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 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); 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 = `

Публичный адрес 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 берёт ваш локальный client.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 { 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; }