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

292 lines
13 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: 1700px; }
h1 { margin: 8px 0; }
.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: 260px; 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; }
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>Текущее состояние (очередь 1)</h3>
<div id="stateInfo" class="muted">Загрузка...</div>
</div>
<div class="panel">
<h3>Покупка билета в 1-й очереди</h3>
<div class="muted">Покупка создает билет только в 1-й очереди. Билеты 2-й очереди добавляются менеджерами с правами.</div>
<div class="row">
<label>Сумма (SOL): <input id="amountSol" value="0.1" /></label>
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label>
</div>
<div class="row">
<button id="buyBtn">Купить билет</button>
</div>
<div id="buyResult" class="muted"></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 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",
};
const COEF_SCALE = 1_000_000n;
let walletPubkey = 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));
}
function solToLamports(solStr) {
const v = Number(solStr);
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
return BigInt(Math.round(v * 1_000_000_000));
}
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 parseConfig(data) {
let o = 0;
const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)).toBase58(); 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 limit = readU64(data, o); o += 8;
const reward = readU64(data, o); o += 8;
return { version, coefPpm, limit, 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 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 refreshState();
}
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 derivePdas() {
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 };
}
async function loadCoreState() {
const pdas = derivePdas();
const [cfgAi, coefAi, queuesAi] = await Promise.all([
connection.getAccountInfo(pdas.configPda, "confirmed"),
connection.getAccountInfo(pdas.coefPda, "confirmed"),
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
]);
if (!cfgAi || !coefAi || !queuesAi) throw new Error("PDA не инициализированы. Сначала выполните init на странице админки.");
const config = parseConfig(cfgAi.data);
const coef = parseCoef(coefAi.data);
const queues = parseQueues(queuesAi.data);
return { pdas, config, coef, queues };
}
async function refreshState() {
const el = document.getElementById("stateInfo");
try {
const { config, coef, queues } = await loadCoreState();
const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6));
const totalBefore = queues.q1Total;
const totalBeforeSum = queues.q1SumTotal;
const pendingBeforeCount = queues.q1Total - queues.q1Paid;
const pendingBeforeSum = queues.q1SumTotal - queues.q1SumPaid;
const nextTicketIndex = queues.q1Total + 1n;
const remainingByTotal = coef.limit > queues.q1SumTotal ? (coef.limit - queues.q1SumTotal) : 0n;
const paused = queues.q1SumTotal >= coef.limit;
el.innerHTML = `
<div>DAO: <code>${config.dao}</code></div>
<div>Inflow vault: <code>${config.inflow}</code></div>
<div class="muted">Inflow vault — входящий PDA-кошелек выплат программы.</div>
<div>Коэффициент: <b>${coefText}</b></div>
<div>Лимит очереди 1: <b>${lamportsToSolStr(coef.limit)} SOL</b></div>
<div>Награда за шаг выплат: <b>${lamportsToSolStr(coef.reward)} SOL</b></div>
<div>До вас всего билетов (исторически): <b>${totalBefore.toString()}</b></div>
<div>До вас всего сумма билетов (исторически): <b>${lamportsToSolStr(totalBeforeSum)} SOL</b></div>
<div>Из них сейчас не выплачено билетов: <b>${pendingBeforeCount.toString()}</b></div>
<div>Из них сейчас не выплачено по сумме: <b>${lamportsToSolStr(pendingBeforeSum)} SOL</b></div>
<div>Ваш билет будет номером: <b>${nextTicketIndex.toString()}</b></div>
<div>Осталось актуального лимита до изменения коэффициента: <b>${lamportsToSolStr(remainingByTotal)} SOL</b></div>
<div class="${paused ? "warn" : "ok"}">${paused ? "Покупка временно приостановлена: лимит заполнен." : "Покупка доступна."}</div>
`;
} catch (e) {
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
}
}
async function buyTicket() {
const out = document.getElementById("buyResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) {
await connectWallet();
} else if (!provider.isConnected) {
await provider.connect();
}
const { pdas, config, coef, queues } = await loadCoreState();
if (queues.q1SumTotal >= coef.limit) {
out.innerHTML = `<span class="warn">Пока временно приостановлено: очередь заполнена. После изменения коэффициента/лимита покупка снова заработает.</span>`;
return;
}
const amountLamports = solToLamports(document.getElementById("amountSol").value.trim());
const recipientRaw = document.getElementById("recipient").value.trim();
const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey;
const nextIndex = queues.q1Total + 1n;
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync(
[utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)],
PROGRAM_ID
);
const disc = await ixDiscriminator("buy_ticket");
const data = concat(disc, u64ToBytes(amountLamports), recipient.toBytes());
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: pdas.configPda, isSigner: false, isWritable: true },
{ pubkey: pdas.coefPda, isSigner: false, isWritable: true },
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: ticketPda, isSigner: false, isWritable: true },
{ pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true },
{ pubkey: solanaWeb3.SystemProgram.programId, 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 refreshState();
} catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refreshState);
document.getElementById("buyBtn").addEventListener("click", buyTicket);
refreshState();
</script>
</body>
</html>