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

508 lines
24 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>
:root {
color-scheme: dark;
--bg: #0f1218;
--panel: #171b24;
--text: #e8edf6;
--muted: #97a3b8;
--line: #2a3242;
--ok: #55d48a;
--warn: #ffbf5e;
--err: #ff7d7d;
--btn: #273247;
--btn-hover: #32415c;
--code: #1e2633;
}
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; margin: 20px; background: var(--bg); color: var(--text); }
.topbar { margin-bottom: 12px; }
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
.wrap { width: 100%; max-width: 1850px; }
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); width: 100%; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 9px 10px; min-width: 240px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
button:hover { background: var(--btn-hover); }
.muted { color: var(--muted); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.err { color: var(--err); white-space: pre-wrap; }
.paid { color: var(--ok); font-weight: 700; }
.waiting { color: var(--muted); }
.xfer { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<div class="wrap">
<div class="topbar"><a class="back" href="./index.html">На главную</a></div>
<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 class="warn">Вызывающий шаг выплат платит сетевую комиссию транзакции и получает on-chain награду. Идея в том, что награда делает вызов экономически выгодным, поэтому всегда есть мотивация нажимать кнопку шага выплат.</div>
<div class="muted">Автоматического таймера в контракте нет: в Solana любая инструкция должна быть инициирована внешним вызовом.</div>
</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("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
const RPC_URL = "https://api.devnet.solana.com";
const ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = {
config: "shine_payments_v3_config",
coef: "shine_payments_v3_coef_limit",
queues: "shine_payments_v3_queues",
ticketQ1: "shine_payments_v3_q1_ticket",
ticketQ2: "shine_payments_v3_q2_ticket",
};
const LAMPORTS_PER_SOL = 1_000_000_000n;
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 readI32(data, offset) {
let x = Number(readU64(data, offset) & 0xffffffffn);
if (x > 0x7fffffff) x -= 0x100000000;
return x;
}
function readI64(data, offset) {
let x = readU64(data, offset);
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
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));
}
function centsToUsdStr(c) {
return trimZeros((Number(c) / 100).toFixed(2));
}
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 parsePythPriceUpdateV2(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 { num, den, publishTime };
}
function usdCentsToLamportsCeil(usdCents, px) {
const n = usdCents * LAMPORTS_PER_SOL * px.den;
return (n + px.num - 1n) / px.num;
}
function usdCentsToSolStr(usdCents, px) {
return lamportsToSolStr(usdCentsToLamportsCeil(usdCents, px));
}
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;
return { version, dao, manager, inflow };
}
function parseCoef(data) {
let o = 0;
const version = data[o++];
const coefPpm = readU64(data, o); o += 8;
const limitUsdCents = readU64(data, o); o += 8;
const reward = readU64(data, o); o += 8;
return { version, coefPpm, limitUsdCents, 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 payoutUsdCents = readU64(data, o); o += 8;
const debtBeforeUsdCents = readU64(data, o); o += 8;
return { version, queueId, index, isPaid, recipient, payoutUsdCents, debtBeforeUsdCents };
}
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 [coefPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.coef)], PROGRAM_ID);
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
return { configPda, coefPda, 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, coefAi, qAi, oracleAi] = await Promise.all([
connection.getAccountInfo(pdas.configPda, "confirmed"),
connection.getAccountInfo(pdas.coefPda, "confirmed"),
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
connection.getAccountInfo(ORACLE_ACCOUNT, "confirmed"),
]);
if (!cfgAi || !coefAi || !qAi) throw new Error("PDA не инициализированы. Запустите init на странице админки.");
if (!oracleAi) throw new Error("SOL/USD oracle account не найден");
const config = parseConfig(cfgAi.data);
const coef = parseCoef(coefAi.data);
const queues = parseQueues(qAi.data);
const pyth = parsePythPriceUpdateV2(oracleAi.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, coef, queues, pyth, 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;
}
function nextPayoutTicket(queues) {
const queue = nextStepQueue(queues);
if (queue === 0) return null;
const index = queue === 1 ? (queues.q1Paid + 1n) : (queues.q2Paid + 1n);
return { queue, index };
}
async function refreshPayoutInfo() {
const el = document.getElementById("payoutInfo");
try {
const core = await loadCoreState();
const queue = nextStepQueue(core.queues);
const pythAge = Math.max(0, Math.floor(Date.now() / 1000 - Number(core.pyth.publishTime)));
if (queue === 0) {
el.innerHTML = `
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(core.pyth.num) / Number(core.pyth.den) / 100).toFixed(6))}</b>, возраст <b>${pythAge} сек</b></div>
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
<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 ticketLamports = usdCentsToLamportsCeil(next.payoutUsdCents, core.pyth);
const daoUsd = queue === 1 ? next.payoutUsdCents : (next.payoutUsdCents * 2n);
const daoLamports = usdCentsToLamportsCeil(daoUsd, core.pyth);
const need = ticketLamports + daoLamports + core.coef.reward;
const missing = core.available >= need ? 0n : (need - core.available);
el.innerHTML = `
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(core.pyth.num) / Number(core.pyth.den) / 100).toFixed(6))}</b>, возраст <b>${pythAge} сек</b></div>
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
<div>Следующий шаг выплат: <b>очередь ${queue}</b></div>
<div>Следующий тикет: <b>#${next.index.toString()}</b></div>
<div>Тикет: <b>${centsToUsdStr(next.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(next.payoutUsdCents, core.pyth)} SOL)</div>
<div>DAO на этом шаге: <b>${centsToUsdStr(daoUsd)} USD</b> (~${lamportsToSolStr(daoLamports)} SOL)</div>
<div>Награда за шаг: <b>${lamportsToSolStr(core.coef.reward)} SOL</b></div>
<div>Нужно для шага: <b>${lamportsToSolStr(need)} SOL</b></div>
<div>Формула: <b>${queue === 1 ? "ticket + dao(1x) + reward" : "ticket + dao(2x) + reward"}</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 next = nextPayoutTicket(core.queues);
const isNext = !!next && next.queue === t.queueId && next.index === t.index;
const isOwner = walletPubkey && walletPubkey.toBase58() === t.recipient.toBase58();
const canTransfer = !t.isPaid && isOwner && !isNext;
const whyBlocked = t.isPaid
? "Тикет уже выплачен"
: !isOwner
? "Передача доступна только текущему получателю тикета"
: isNext
? "Это следующий тикет на выплату, передача заблокирована"
: "";
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>${centsToUsdStr(t.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(t.payoutUsdCents, core.pyth)} SOL)</div>
<div>Изначально очередь перед тикетом: <b>${centsToUsdStr(t.debtBeforeUsdCents)} USD</b></div>
<div class="xfer">
<div><b>Передача билета</b></div>
<div class="row">
<input id="newRecipient_${t.queueId}_${t.index.toString()}" placeholder="Новый получатель (Base58)" />
<button
class="transferBtn"
data-queue="${t.queueId}"
data-index="${t.index.toString()}"
data-pda="${pda.toBase58()}"
${canTransfer ? "" : "disabled"}
>Передать</button>
</div>
<div id="transferResult_${t.queueId}_${t.index.toString()}" class="${canTransfer ? "muted" : "warn"}">${canTransfer ? "Доступно для текущего владельца тикета." : whyBlocked}</div>
</div>
</div>
`;
}
async function changeTicketRecipient(queueId, index, ticketPdaBase58) {
const resultEl = document.getElementById(`transferResult_${queueId}_${index}`);
const inputEl = document.getElementById(`newRecipient_${queueId}_${index}`);
resultEl.className = "muted";
resultEl.textContent = "";
try {
if (!walletPubkey) await connectWallet();
const newRecipientRaw = (inputEl.value || "").trim();
if (!newRecipientRaw) throw new Error("Введите адрес нового получателя");
const newRecipient = new solanaWeb3.PublicKey(newRecipientRaw);
const core = cachedCore || await loadCoreState();
const disc = await ixDiscriminator("change_ticket_recipient");
const data = concat(disc, newRecipient.toBytes());
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: new solanaWeb3.PublicKey(ticketPdaBase58), isSigner: false, isWritable: true },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
resultEl.className = "ok";
resultEl.innerHTML = `Передача выполнена. Tx: <code>${sig}</code>`;
await refreshAll();
await findTickets();
} catch (e) {
resultEl.className = "err";
resultEl.textContent = String(e.message || e);
}
}
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.pdas.coefPda, 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 },
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
];
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);
document.getElementById("ticketResult").addEventListener("click", (e) => {
const btn = e.target.closest(".transferBtn");
if (!btn) return;
const queueId = Number(btn.dataset.queue);
const index = btn.dataset.index;
const pda = btn.dataset.pda;
changeTicketRecipient(queueId, index, pda);
});
refreshAll();
</script>
</body>
</html>