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

362 lines
16 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: 260px; }
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",
coef: "shine_payments_v2_coef_limit",
queues: "shine_payments_v2_queues",
ticketQ1: "shine_payments_v2_q1_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) {
const sol = Number(l) / 1_000_000_000;
return trimZeros(sol.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 deriveQ1TicketPda(index) {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), 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;
}
async function refreshPayoutInfo() {
const el = document.getElementById("payoutInfo");
try {
const core = await loadCoreState();
const q1Pending = core.queues.q1Total - core.queues.q1Paid;
const q2Pending = core.queues.q2Total - core.queues.q2Paid;
if (q1Pending === 0n && q2Pending === 0n) {
el.innerHTML = `
<div>Обе очереди пусты/полностью выплачены.</div>
<div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
<div class="warn">При шаге выплат эта сумма будет переведена в DAO, награда вызывающему не начисляется.</div>
`;
return;
}
if (q1Pending === 0n && q2Pending > 0n) {
el.innerHTML = `<div class="warn">Во 2-й очереди есть ожидание, но её выплаты пока не реализованы.</div>`;
return;
}
const nextIndex = core.queues.q1Paid + 1n;
const nextPda = deriveQ1TicketPda(nextIndex);
const nextAi = await connection.getAccountInfo(nextPda, "confirmed");
if (!nextAi) {
el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()}</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>#${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>`;
}
}
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);
const pda = deriveQ1TicketPda(idx);
const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) throw new Error(`Тикет #${idx.toString()} не найден`);
const t = parseTicket(ai.data);
results.push({ pda, t });
} else if (walletRaw) {
const recipient = new solanaWeb3.PublicKey(walletRaw);
for (let i = 1n; i <= core.queues.q1Total; i++) {
const pda = deriveQ1TicketPda(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("Введите номер тикета или кошелек");
}
const nextIndex = core.queues.q1Paid + 1n;
const lines = results.map(({ pda, t }) => {
const isCurrent = !t.isPaid && t.index === nextIndex;
const inFront = t.index > nextIndex ? (t.index - nextIndex) : 0n;
const remainingToThis = t.debtBefore > core.queues.q1SumPaid ? (t.debtBefore - core.queues.q1SumPaid) : 0n;
const missingInsideCurrentTicket = isCurrent && core.available < t.payout ? (t.payout - core.available) : 0n;
return `
<div class="panel">
<div>Тикет #<b>${t.index.toString()}</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>
${isCurrent ? `<div class="warn">Это текущий билет к выплате.</div>` : ``}
${isCurrent && missingInsideCurrentTicket > 0n
? `<div class="warn">Для выплаты именно этого билета внутри его суммы не хватает: <b>${lamportsToSolStr(missingInsideCurrentTicket)} SOL</b>.</div>`
: ``}
</div>
`;
});
out.innerHTML = lines.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 q1Pending = core.queues.q1Total - core.queues.q1Paid;
let nextTicketPda;
let recipient;
if (q1Pending > 0n) {
const nextIndex = core.queues.q1Paid + 1n;
nextTicketPda = deriveQ1TicketPda(nextIndex);
const ai = await connection.getAccountInfo(nextTicketPda, "confirmed");
if (!ai) throw new Error(`Следующий тикет #${nextIndex.toString()} не найден`);
recipient = parseTicket(ai.data).recipient;
} else {
nextTicketPda = deriveQ1TicketPda(core.queues.q1Paid + 1n);
recipient = walletPubkey;
}
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>