362 lines
16 KiB
HTML
362 lines
16 KiB
HTML
<!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>
|