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

261 lines
11 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; }
h1 { margin-bottom: 8px; }
.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: 220px; }
button { padding: 8px 12px; cursor: pointer; }
.muted { color: #666; }
.ok { color: #0a7a3c; }
.warn { color: #9f5f00; }
.err { color: #b30000; white-space: pre-wrap; }
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 id="stateInfo" class="muted">Загрузка...</div>
</div>
<div class="panel">
<h3>Покупка билета в 1-й очереди</h3>
<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>
<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",
};
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) {
const sol = Number(l) / 1_000_000_000;
return trimZeros(sol.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;
const reward = readU64(data, o); o += 8;
return { version, dao, manager, inflow, reward };
}
function parseCoef(data) {
let o = 0;
const version = data[o++];
const coefPpm = readU64(data, o); o += 8;
const limit = readU64(data, o); o += 8;
return { version, coefPpm, limit };
}
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;
}
async 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 = await 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 currentDebt = queues.q1SumTotal - queues.q1SumPaid;
const pendingTickets = queues.q1Total - queues.q1Paid;
const remaining = coef.limit > currentDebt ? coef.limit - currentDebt : 0n;
const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6));
const paused = currentDebt >= 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>Текущий долг очереди (1): <b>${lamportsToSolStr(currentDebt)} SOL</b></div>
<div>Билетов в ожидании до вас сейчас: <b>${pendingTickets.toString()}</b></div>
<div>Осталось до изменения коэффициента/лимита: <b>${lamportsToSolStr(remaining)} 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();
const currentDebt = queues.q1SumTotal - queues.q1SumPaid;
if (currentDebt >= 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>