import { renderHeader } from '../components/header.js'; import { state } from '../state.js'; import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; export const pageMeta = { id: 'queue', title: 'Очередь билета' }; const SHINE_PAYMENTS_PROGRAM_ID = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v'; const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE'; const SHINE_PAYMENTS_SEEDS = { config: 'shine_payments_config', coef: 'shine_payments_coef_limit', queues: 'shine_payments_queues', q1: 'shine_payments_q1_ticket', q2: 'shine_payments_q2_ticket', q3: 'shine_payments_q3_ticket', }; function utf8Bytes(text) { return new TextEncoder().encode(String(text || '')); } 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 formatQueuePrefix(queueId) { if (queueId === 2) return 'Q2'; if (queueId === 3) return 'Q3'; return 'Q1'; } function formatUsdCentsText(cents) { const value = Number(cents || 0n) / 100; return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } 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 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 [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 }; }; return { connection, programId, 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 styleInputField(field) { if (!field) return; field.style.color = '#111111'; field.style.webkitTextFillColor = '#111111'; field.style.caretColor = '#111111'; field.style.backgroundColor = '#ffffff'; } function readTicketFromUrl() { try { const params = new URLSearchParams(window.location.search || ''); return String(params.get('ticket') || '').trim(); } catch { return ''; } } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; const content = document.createElement('div'); content.className = 'stack'; const status = document.createElement('p'); status.className = 'meta-muted'; status.textContent = 'Страница готова. Введите номер билета.'; 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', 'public-queue-ticket-query'); inputLabel.textContent = 'Номер билета'; const queryInput = document.createElement('input'); queryInput.id = 'public-queue-ticket-query'; queryInput.type = 'text'; queryInput.placeholder = 'Например: 12, 2-5 или 3 8'; queryInput.autocomplete = 'off'; queryInput.spellcheck = false; styleInputField(queryInput); queryInput.value = readTicketFromUrl(); const actions = document.createElement('div'); actions.className = 'row'; actions.innerHTML = ` `; const result = document.createElement('div'); result.className = 'card stack'; result.innerHTML = `

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

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

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

`; status.textContent = 'Введите номер билета.'; 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()} пока не создан.

`; status.textContent = `Билет ${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 в очереди до него
`; status.textContent = `Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`; } catch (error) { result.innerHTML = `

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

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

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

`; status.textContent = 'Поле билета очищено.'; }); queryInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void renderTicketInfo(); } }); content.append(card, inputLabel, queryInput, actions, result, status); screen.append( renderHeader({ title: 'Очередь билета', leftAction: { label: '←', onClick: () => navigate('start-view') }, }), content, ); if (queryInput.value) { void renderTicketInfo(); } return screen; }