SHA256
421 lines
16 KiB
JavaScript
421 lines
16 KiB
JavaScript
import { createTopBar } from '../components/topbar.js';
|
|
import { defaultSolanaCluster } from '../deploy-config.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_MAINNET_SOLANA_ENDPOINT = 'https://solana-rpc.publicnode.com';
|
|
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 isTestDeployUi() {
|
|
return String(defaultSolanaCluster || '').trim().toLowerCase() !== 'mainnet-beta';
|
|
}
|
|
|
|
function resolveQueueEndpoint(useMainnetQueue) {
|
|
return useMainnetQueue ? SHINE_MAINNET_SOLANA_ENDPOINT : state.entrySettings.solanaServer;
|
|
}
|
|
|
|
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 formatQueueTitle(queueId) {
|
|
if (queueId === 2) return 'Вторая очередь';
|
|
if (queueId === 3) return 'Третья очередь';
|
|
return 'Первая очередь';
|
|
}
|
|
|
|
function formatSupportTicketLabel(queueId, index) {
|
|
const cleanIndex = BigInt(index || 0n).toString();
|
|
if (queueId === 2 || queueId === 3) return `${queueId}/${cleanIndex}`;
|
|
return cleanIndex;
|
|
}
|
|
|
|
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(/^([23])\s*\/\s*(\d+)$/);
|
|
if (!match) {
|
|
throw new Error('Формат: для первой очереди просто номер, для второй и третьей - например 2/7 или 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, chrome}) {
|
|
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 = `
|
|
<h2 style="margin:0;">Публичная очередь билета</h2>
|
|
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
|
Введите номер билета.
|
|
</p>
|
|
`;
|
|
|
|
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 = `
|
|
<button class="primary-btn" type="button" id="public-queue-load" style="width:100%;">Показать билет</button>
|
|
<button class="ghost-btn" type="button" id="public-queue-reset" style="width:100%;">Сбросить</button>
|
|
`;
|
|
|
|
const result = document.createElement('div');
|
|
result.className = 'card stack';
|
|
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
|
|
|
|
const testDeploy = isTestDeployUi();
|
|
const mainnetRow = document.createElement('label');
|
|
mainnetRow.className = 'checkbox-row';
|
|
mainnetRow.innerHTML = `
|
|
<input type="checkbox" id="public-queue-mainnet-source" ${testDeploy ? 'checked' : ''} />
|
|
<span>Показывать данные mainnet (${SHINE_MAINNET_SOLANA_ENDPOINT})</span>
|
|
`;
|
|
mainnetRow.style.display = testDeploy ? '' : 'none';
|
|
const mainnetCheckbox = mainnetRow.querySelector('#public-queue-mainnet-source');
|
|
|
|
let lastCoreState = null;
|
|
let lastEndpoint = '';
|
|
|
|
async function renderTicketInfo() {
|
|
const raw = String(queryInput.value || '').trim();
|
|
if (!raw) {
|
|
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета.</p>`;
|
|
status.textContent = 'Введите номер билета.';
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = parseSupportTicketInput(raw);
|
|
const endpoint = resolveQueueEndpoint(Boolean(testDeploy && mainnetCheckbox?.checked));
|
|
const core = lastCoreState && lastEndpoint === endpoint
|
|
? lastCoreState
|
|
: await loadSupportPaymentsCore(endpoint);
|
|
lastCoreState = core;
|
|
lastEndpoint = endpoint;
|
|
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 = `
|
|
<p class="meta-muted" style="margin:0;">Билет ${formatSupportTicketLabel(parsed.queueId, parsed.index)} пока не создан.</p>
|
|
`;
|
|
status.textContent = `Билет ${formatSupportTicketLabel(parsed.queueId, parsed.index)} не найден.`;
|
|
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;
|
|
if (ticket.queueId === 2 || ticket.queueId === 3) {
|
|
result.innerHTML = `
|
|
<div>${formatQueueTitle(ticket.queueId)}</div>
|
|
<div><b>Номер билета:</b> ${ticket.index.toString()}</div>
|
|
<div><b>Статус:</b> ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}</div>
|
|
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
|
|
<div><b>Сумма:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
|
|
`;
|
|
} else {
|
|
result.innerHTML = `
|
|
<div><b>Билет:</b> ${formatSupportTicketLabel(parsed.queueId, ticket.index)}</div>
|
|
<div><b>Статус:</b> ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}</div>
|
|
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
|
|
<div><b>До него в очереди:</b> ${ticketsBefore.toString()} билетов</div>
|
|
<div><b>Из них уже выплачено:</b> ${paidBefore.toString()} билетов</div>
|
|
<div><b>Ещё осталось до него:</b> ${remainingBefore.toString()} билетов</div>
|
|
<div><b>Уже выплачено по сумме в очереди:</b> ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD</div>
|
|
<div><b>Сумма этого билета:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
|
|
<div><b>Накоплено до этого билета:</b> ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него</div>
|
|
`;
|
|
}
|
|
status.textContent = `Билет ${formatSupportTicketLabel(ticket.queueId, ticket.index)} найден. Источник Solana: ${endpoint}`;
|
|
} catch (error) {
|
|
result.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить билет')}</p>`;
|
|
status.textContent = `Не удалось посмотреть билет: ${error?.message || 'unknown'}`;
|
|
}
|
|
}
|
|
|
|
actions.querySelector('#public-queue-load')?.addEventListener('click', () => {
|
|
void renderTicketInfo();
|
|
});
|
|
mainnetCheckbox?.addEventListener('change', () => {
|
|
lastCoreState = null;
|
|
lastEndpoint = '';
|
|
status.textContent = `Источник Solana: ${resolveQueueEndpoint(Boolean(testDeploy && mainnetCheckbox.checked))}`;
|
|
});
|
|
actions.querySelector('#public-queue-reset')?.addEventListener('click', () => {
|
|
queryInput.value = '';
|
|
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
|
|
status.textContent = 'Поле билета очищено.';
|
|
});
|
|
queryInput.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
void renderTicketInfo();
|
|
}
|
|
});
|
|
|
|
content.append(card, mainnetRow, inputLabel, queryInput, actions, result, status);
|
|
chrome?.setTopbar(createTopBar({
|
|
title: 'Очередь билета',
|
|
back: { label: '←', onClick: () => navigate('start-view') },
|
|
}));
|
|
screen.append(
|
|
content,
|
|
);
|
|
|
|
if (queryInput.value) {
|
|
void renderTicketInfo();
|
|
}
|
|
|
|
return screen;
|
|
}
|