437 lines
21 KiB
HTML
437 lines
21 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>
|
||
: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); }
|
||
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, 74);
|
||
const exponent = readI32(data, 90);
|
||
const publishTime = readI64(data, 94);
|
||
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;
|
||
}
|
||
|
||
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) {
|
||
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>
|
||
`;
|
||
}
|
||
|
||
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);
|
||
refreshAll();
|
||
</script>
|
||
</body>
|
||
</html>
|