diff --git a/Dev_Docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md b/Dev_Docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md new file mode 100644 index 0000000..bbe28be --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-28_1930_поддержка_проекта_сияние.md @@ -0,0 +1,38 @@ +# Поддержать проект Сияние + +Статус: `pending` + +## Кратко + +В `shine-UI/js/pages/wallet-view.js` добавлен новый раздел `Поддержать проект Сияние` с тремя входами: + +1. купить билет; +2. посмотреть билет по номеру; +3. сгенерировать новую пару ключей. + +## Что проверить + +1. Открыть `Кошелёк`. +2. Перейти в `Поддержать проект Сияние`. +3. Проверить экран покупки: + - виден коэффициент; + - виден остаток лимита очереди 1; + - виден расчет в SOL; + - кнопка `Справка` открывает отдельный экран; + - покупка блокируется, если сумма больше остатка лимита. +4. Проверить экран просмотра: + - `12` ищется как билет очереди 1; + - `2-5` и `3 8` ищутся как билеты очередей 2 и 3; + - показываются статус, количество билетов до него и уже выплаченные значения. +5. Проверить генератор ключей: + - генерируется новая пара ключей; + - публичный и секретный ключи показываются; + - можно скопировать и скачать результат; + - дополнительный текст в поле необязателен. + +## Ожидаемый результат + +- Экран раздела поддержки открывается из `wallet-view`. +- Покупка билета выполняется по текущему курсу и с допуском 3%. +- По номеру билета показывается понятная сводка по очереди. +- Генерация ключей использует безопасный браузерный рандом и не требует сохранения секретного ключа. diff --git a/VERSION.properties b/VERSION.properties index c42b1b3..f2b62bc 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.285 -server.version=1.2.265 +client.version=1.2.286 +server.version=1.2.266 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 9b39f73..03c97c8 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -50,7 +50,7 @@ import * as keyStorageView from './pages/key-storage-view.js'; import * as profileView from './pages/profile-view.js'; import * as profileEditView from './pages/profile-edit-view.js'; -import * as walletView from './pages/wallet-view.js?v=202605300007'; +import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; diff --git a/shine-UI/js/pages/wallet-view.js b/shine-UI/js/pages/wallet-view.js index bfe647c..95dda6b 100644 --- a/shine-UI/js/pages/wallet-view.js +++ b/shine-UI/js/pages/wallet-view.js @@ -17,6 +17,7 @@ import { 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, @@ -65,6 +66,312 @@ function sessionArgsOrThrow() { 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; +} + +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: solana.bs58.encode(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'; @@ -99,6 +406,640 @@ export function render({ navigate }) { arweaveWalletCtx = null; } + 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 renderSupportBuy(); + }); + 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 = renderSupportBuy) { + 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; + + 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 = 'Появится после генерации'; + + 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; + + 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; + + 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 renderSupportBuy() { + const modeToken = ++activeModeToken; + clearArweaveSecretsInMemory(); + content.innerHTML = ''; + + const backBtn = createModeBackButton(renderSupportHub); + const helpCard = document.createElement('div'); + helpCard.className = 'card stack'; + helpCard.innerHTML = ` +

Купить билет

+

+ Вы покупаете только билет очереди 1. Сумма задается в USD, а оплата уходит в SOL по курсу USDT/SOL на момент транзакции. + Покупка подписывается вашим client key. Интерфейс проверяет допуск по курсу до 3 процентов и не дает выходить за текущий лимит по коэффициенту. +

+ `; + + 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'; + + 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; + 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; + } + }; + + 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(renderSupportBuy); + }); + + 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(); @@ -135,7 +1076,15 @@ export function render({ navigate }) { void renderShineBlockchainWallet(); }); - card.append(solanaBtn, arweaveBtn, shineBchBtn); + 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('Выберите тип кошелька.'); }