shine-solana/shine/programs/shine_payments/web/track_ticket.html

371 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Отслеживание билета — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 240px; }
button { padding: 8px 12px; cursor: pointer; }
.muted { color: #666; }
.ok { color: #0a7a3c; }
.warn { color: #9f5f00; }
.err { color: #b30000; white-space: pre-wrap; }
.paid { color: #0a7a3c; font-weight: 700; }
.waiting { color: #666; }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<h1>Отслеживание билета (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel">
<div class="row">
<button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить</button>
</div>
<div id="walletInfo" class="muted"></div>
</div>
<div class="panel">
<h3>Поиск билетов</h3>
<div class="row">
<label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label>
<label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label>
<button id="findBtn">Найти</button>
</div>
<div id="ticketResult" class="muted"></div>
</div>
<div class="panel">
<h3>Состояние шага выплат</h3>
<div id="payoutInfo" class="muted">Загрузка...</div>
<div class="row">
<button id="stepBtn">Сделать шаг выплат</button>
</div>
<div id="stepResult" class="muted"></div>
</div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE");
const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = {
config: "shine_payments_v2_config",
queues: "shine_payments_v2_queues",
ticketQ1: "shine_payments_v2_q1_ticket",
ticketQ2: "shine_payments_v2_q2_ticket",
};
let walletPubkey = null;
let cachedCore = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
function utf8(s) { return new TextEncoder().encode(s); }
function u64ToBytes(v) {
let x = BigInt(v);
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
return out;
}
function readU64(data, offset) {
let x = 0n;
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x;
}
function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
}
function lamportsToSolStr(l) {
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
}
async function ixDiscriminator(name) {
const msg = utf8("global:" + name);
const hash = await crypto.subtle.digest("SHA-256", msg);
return new Uint8Array(hash).slice(0, 8);
}
function isNotEnoughForStep(msg) {
const s = String(msg || "").toLowerCase();
return s.includes("notenoughinflowforstep") || s.includes("0x177a");
}
function parseConfig(data) {
let o = 0;
const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const reward = readU64(data, o); o += 8;
return { version, dao, manager, inflow, reward };
}
function parseQueues(data) {
let o = 0;
const version = data[o++];
const q1Total = readU64(data, o); o += 8;
const q1Paid = readU64(data, o); o += 8;
const q1SumTotal = readU64(data, o); o += 8;
const q1SumPaid = readU64(data, o); o += 8;
const q2Total = readU64(data, o); o += 8;
const q2Paid = readU64(data, o); o += 8;
const q2SumTotal = readU64(data, o); o += 8;
const q2SumPaid = readU64(data, o); o += 8;
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
}
function parseTicket(data) {
let o = 0;
const version = data[o++];
const queueId = data[o++];
const index = readU64(data, o); o += 8;
const isPaid = data[o++] === 1;
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const payout = readU64(data, o); o += 8;
const debtBefore = readU64(data, o); o += 8;
return { version, queueId, index, isPaid, recipient, payout, debtBefore };
}
function getProvider() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
return window.solana;
}
async function connectWallet() {
const provider = getProvider();
const r = await provider.connect();
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
await refreshAll();
}
async function sendInstruction(ix) {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
const tx = new solanaWeb3.Transaction().add(ix);
tx.feePayer = walletPubkey;
const bh = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = bh.blockhash;
const signed = await provider.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
return sig;
}
function deriveCorePdas() {
const [configPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
return { configPda, queuesPda };
}
function deriveTicketPda(queueId, index) {
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
return pda;
}
async function loadCoreState() {
const pdas = deriveCorePdas();
const [cfgAi, qAi] = await Promise.all([
connection.getAccountInfo(pdas.configPda, "confirmed"),
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
]);
if (!cfgAi || !qAi) throw new Error("PDA не инициализированы. Запустите init на странице админки.");
const config = parseConfig(cfgAi.data);
const queues = parseQueues(qAi.data);
const inflowAi = await connection.getAccountInfo(config.inflow, "confirmed");
if (!inflowAi) throw new Error("Inflow vault отсутствует");
const rentMin = await connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed");
const available = BigInt(Math.max(0, inflowAi.lamports - rentMin));
cachedCore = { pdas, config, queues, inflowAi, available };
return cachedCore;
}
function nextStepQueue(queues) {
const q1Pending = queues.q1Total - queues.q1Paid;
const q2Pending = queues.q2Total - queues.q2Paid;
if (q1Pending > 0n) return 1;
if (q2Pending > 0n) return 2;
return 0;
}
async function refreshPayoutInfo() {
const el = document.getElementById("payoutInfo");
try {
const core = await loadCoreState();
const queue = nextStepQueue(core.queues);
if (queue === 0) {
el.innerHTML = `
<div>Обе очереди пусты/полностью выплачены.</div>
<div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
<div class="warn">При шаге выплат эта сумма будет переведена в DAO, награда вызывающему не начисляется.</div>
`;
return;
}
const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
const nextPda = deriveTicketPda(queue, nextIndex);
const nextAi = await connection.getAccountInfo(nextPda, "confirmed");
if (!nextAi) {
el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()} для очереди ${queue}</div>`;
return;
}
const next = parseTicket(nextAi.data);
const need = next.payout * 2n + core.config.reward;
const missing = core.available >= need ? 0n : (need - core.available);
el.innerHTML = `
<div>Следующий шаг выплат: <b>очередь ${queue}</b></div>
<div>Следующий тикет: <b>#${next.index.toString()}</b></div>
<div>Выплата по тикету: <b>${lamportsToSolStr(next.payout)} SOL</b></div>
<div>Нужно для шага (выплата + DAO + награда): <b>${lamportsToSolStr(need)} SOL</b></div>
<div>Доступно в inflow vault: <b>${lamportsToSolStr(core.available)} SOL</b></div>
<div>${missing === 0n
? '<span class="ok">Хватает для шага выплаты.</span>'
: `<span class="warn">Не хватает: ${lamportsToSolStr(missing)} SOL</span>`
}</div>
`;
} catch (e) {
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
}
}
function renderTicketCard(core, pda, t) {
const nextQ1 = core.queues.q1Paid + 1n;
const nextQ2 = core.queues.q2Paid + 1n;
const isCurrentQ1 = !t.isPaid && t.queueId === 1 && t.index === nextQ1;
const isCurrentQ2 = !t.isPaid && t.queueId === 2 && (core.queues.q1Total - core.queues.q1Paid) === 0n && t.index === nextQ2;
const inFront = t.queueId === 1
? (t.index > nextQ1 ? (t.index - nextQ1) : 0n)
: (t.index > nextQ2 ? (t.index - nextQ2) : 0n);
const sumPaid = t.queueId === 1 ? core.queues.q1SumPaid : core.queues.q2SumPaid;
const remainingToThis = t.debtBefore > sumPaid ? (t.debtBefore - sumPaid) : 0n;
const missingInsideCurrent = (isCurrentQ1 || isCurrentQ2) && core.available < t.payout ? (t.payout - core.available) : 0n;
return `
<div class="panel">
<div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
<div>PDA: <code>${pda.toBase58()}</code></div>
<div>Получатель: <code>${t.recipient.toBase58()}</code></div>
<div>Сумма выплаты: <b>${lamportsToSolStr(t.payout)} SOL</b></div>
<div>Билетов перед ним сейчас: <b>${inFront.toString()}</b></div>
<div>До его выплаты по сумме в предыдущих билетах осталось: <b>${lamportsToSolStr(remainingToThis)} SOL</b></div>
${t.queueId === 2 && !t.isPaid ? `<div class="warn">Для 2-й очереди оценка не окончательная: 1-я очередь может увеличиваться.</div>` : ``}
${(isCurrentQ1 || isCurrentQ2) ? `<div class="warn">Это текущий билет к выплате.</div>` : ``}
${((isCurrentQ1 || isCurrentQ2) && missingInsideCurrent > 0n)
? `<div class="warn">Для выплаты именно этого билета внутри его суммы не хватает: <b>${lamportsToSolStr(missingInsideCurrent)} SOL</b>.</div>`
: ``}
</div>
`;
}
async function findTickets() {
const out = document.getElementById("ticketResult");
out.textContent = "";
try {
const core = await loadCoreState();
const idxRaw = document.getElementById("ticketIndex").value.trim();
const walletRaw = document.getElementById("recipientWallet").value.trim();
const results = [];
if (idxRaw) {
const idx = BigInt(idxRaw);
for (const queue of [1, 2]) {
const pda = deriveTicketPda(queue, idx);
const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) continue;
results.push({ pda, t: parseTicket(ai.data) });
}
if (results.length === 0) throw new Error(`Тикет #${idx.toString()} не найден ни в одной очереди`);
} else if (walletRaw) {
const recipient = new solanaWeb3.PublicKey(walletRaw);
for (const queue of [1, 2]) {
const total = queue === 1 ? core.queues.q1Total : core.queues.q2Total;
for (let i = 1n; i <= total; i++) {
const pda = deriveTicketPda(queue, i);
const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) continue;
const t = parseTicket(ai.data);
if (t.recipient.toBase58() === recipient.toBase58()) {
results.push({ pda, t });
}
}
}
if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены");
} else {
throw new Error("Введите номер билета или кошелек получателя");
}
out.innerHTML = results.map(({ pda, t }) => renderTicketCard(core, pda, t)).join("");
} catch (e) {
out.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
}
}
async function stepPayout() {
const out = document.getElementById("stepResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) {
await connectWallet();
} else if (!provider.isConnected) {
await provider.connect();
}
const core = cachedCore || await loadCoreState();
const queue = nextStepQueue(core.queues);
let nextTicketPda;
let recipient;
if (queue === 0) {
nextTicketPda = deriveTicketPda(1, core.queues.q1Paid + 1n);
recipient = walletPubkey;
} else {
const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
nextTicketPda = deriveTicketPda(queue, nextIndex);
const ai = await connection.getAccountInfo(nextTicketPda, "confirmed");
if (!ai) throw new Error(`Следующий тикет #${nextIndex.toString()} для очереди ${queue} не найден`);
recipient = parseTicket(ai.data).recipient;
}
const disc = await ixDiscriminator("step_payout");
const data = concat(disc);
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: core.config.inflow, isSigner: false, isWritable: true },
{ pubkey: nextTicketPda, isSigner: false, isWritable: true },
{ pubkey: recipient, isSigner: false, isWritable: true },
{ pubkey: core.config.dao, isSigner: false, isWritable: true },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Шаг выполнен. Tx: <code>${sig}</code></span>`;
await refreshAll();
} catch (e) {
const raw = String(e.message || e);
if (isNotEnoughForStep(raw)) {
out.innerHTML = `<span class="warn">Недостаточно средств для шага выплаты. Это нормальная обработанная ошибка.</span>`;
return;
}
out.innerHTML = `<span class="err">${raw}</span>`;
}
}
async function refreshAll() {
await refreshPayoutInfo();
}
document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refreshAll);
document.getElementById("findBtn").addEventListener("click", findTickets);
document.getElementById("stepBtn").addEventListener("click", stepPayout);
refreshAll();
</script>
</body>
</html>