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

430 lines
21 KiB
HTML
Raw Permalink 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: 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; }
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">Можно купить по USD или по SOL. В очередь и лимиты записываются USD-центы. Выплаты по тикетам считаются в USD, а переводятся в SOL по актуальному курсу Pyth в момент шага выплаты.</div>
<div class="row">
<label>Сумма (USD): <input id="amountUsd" value="20" /></label>
<label>Сумма (SOL): <input id="amountSol" value="0.1" /></label>
<label>Допуск (%): <input id="slippagePct" value="3" /></label>
</div>
<div class="row">
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label>
</div>
<div id="quoteInfo" class="muted"></div>
<div class="row">
<button id="buyUsdBtn">Купить по USD</button>
<button id="buySolBtn">Купить по SOL</button>
</div>
<div class="warn">Дополнительно к сумме покупки кошелек платит сеть за создание записи тикета (обычно около 0.002 SOL).</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("c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW");
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_config",
coef: "shine_payments_coef_limit",
queues: "shine_payments_queues",
ticketQ1: "shine_payments_q1_ticket",
};
const IX = { buyTicketUsd: 5, buyTicketSol: 6 };
const COEF_SCALE = 1_000_000n;
const LAMPORTS_PER_SOL = 1_000_000_000n;
let walletPubkey = null;
let lastState = null;
let activeEdit = "usd";
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(cents) {
return trimZeros((Number(cents) / 100).toFixed(2));
}
function usdTextToCents(text) {
const v = Number(text.trim().replace(",", "."));
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
return BigInt(Math.round(v * 100));
}
function solTextToLamports(text) {
const v = Number(text.trim().replace(",", "."));
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
return BigInt(Math.round(v * 1_000_000_000));
}
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 lamportsToUsdCentsFloor(lamports, px) {
return (lamports * px.num) / (LAMPORTS_PER_SOL * px.den);
}
function usdCentsToLamportsCeil(usdCents, px) {
const n = usdCents * LAMPORTS_PER_SOL * px.den;
return (n + px.num - 1n) / px.num;
}
function applySlippageUp(lamports, pct) {
const bp = BigInt(Math.round(pct * 100));
return (lamports * (10_000n + bp) + 9_999n) / 10_000n;
}
function applySlippageDown(cents, pct) {
const bp = BigInt(Math.round(pct * 100));
return (cents * (10_000n - bp)) / 10_000n;
}
function ixData(tag, ...parts) {
return concat(new Uint8Array([tag]), ...parts);
}
function parseConfig(data) {
let o = 0;
const version = data[o++];
const dao = 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, 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;
const q3Total = readU64(data, o); o += 8;
const q3Paid = readU64(data, o); o += 8;
const q3SumTotal = readU64(data, o); o += 8;
const q3SumPaid = readU64(data, o); o += 8;
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid, q3Total, q3Paid, q3SumTotal, q3SumPaid };
}
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, 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 || !queuesAi) 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(queuesAi.data);
const pyth = parsePythPriceUpdateV2(oracleAi.data);
return { pdas, config, coef, queues, pyth };
}
function renderQuote() {
const el = document.getElementById("quoteInfo");
if (!lastState) { el.textContent = ""; return; }
try {
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
const lamports = solTextToLamports(document.getElementById("amountSol").value);
const payForUsd = usdCentsToLamportsCeil(usdCents, lastState.pyth);
const usdForSol = lamportsToUsdCentsFloor(lamports, lastState.pyth);
const maxLamports = applySlippageUp(payForUsd, slippage);
const minUsd = applySlippageDown(usdForSol, slippage);
el.innerHTML = `
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(lastState.pyth.num) / Number(lastState.pyth.den) / 100).toFixed(6))}</b></div>
<div>Возраст цены: <b>${Math.max(0, Math.floor(Date.now()/1000 - Number(lastState.pyth.publishTime)))} сек</b></div>
<div>Если покупка по USD: к списанию примерно <b>${lamportsToSolStr(payForUsd)} SOL</b>, с допуском максимум <b>${lamportsToSolStr(maxLamports)} SOL</b>.</div>
<div>Если покупка по SOL: это примерно <b>${centsToUsdStr(usdForSol)} USD</b>, с допуском минимум <b>${centsToUsdStr(minUsd)} USD</b>.</div>
`;
} catch (e) {
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
function syncFromUsd() {
if (!lastState) return;
try {
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
const lamports = usdCentsToLamportsCeil(usdCents, lastState.pyth);
document.getElementById("amountSol").value = lamportsToSolStr(lamports);
} catch (_) {}
renderQuote();
}
function syncFromSol() {
if (!lastState) return;
try {
const lamports = solTextToLamports(document.getElementById("amountSol").value);
const usdCents = lamportsToUsdCentsFloor(lamports, lastState.pyth);
document.getElementById("amountUsd").value = centsToUsdStr(usdCents);
} catch (_) {}
renderQuote();
}
async function refreshState() {
const el = document.getElementById("stateInfo");
try {
lastState = await loadCoreState();
const { config, coef, queues, pyth } = lastState;
const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6));
const pendingBeforeCount = queues.q1Total - queues.q1Paid;
const pendingBeforeSum = queues.q1SumTotal - queues.q1SumPaid;
const nextTicketIndex = queues.q1Total + 1n;
const remainingByTotal = coef.limitUsdCents > queues.q1SumTotal ? (coef.limitUsdCents - queues.q1SumTotal) : 0n;
const paused = queues.q1SumTotal >= coef.limitUsdCents;
el.innerHTML = `
<div>DAO: <code>${config.dao}</code></div>
<div>Inflow vault: <code>${config.inflow}</code></div>
<div class="muted">Тестовый DAO-кошелек. В production будет реальный адрес DAO.</div>
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(pyth.num) / Number(pyth.den) / 100).toFixed(6))}</b></div>
<div>Коэффициент: <b>${coefText}</b></div>
<div>Лимит очереди 1: <b>${centsToUsdStr(coef.limitUsdCents)} USD</b></div>
<div>Награда за шаг выплат: <b>${lamportsToSolStr(coef.reward)} SOL</b></div>
<div>Из них сейчас не выплачено билетов: <b>${pendingBeforeCount.toString()}</b></div>
<div>Из них сейчас не выплачено по сумме: <b>${centsToUsdStr(pendingBeforeSum)} USD</b></div>
<div>Ваш билет будет номером: <b>${nextTicketIndex.toString()}</b></div>
<div>Осталось лимита до паузы: <b>${centsToUsdStr(remainingByTotal)} USD</b></div>
<div class="${paused ? "warn" : "ok"}">${paused ? "Покупка временно приостановлена: лимит заполнен." : "Покупка доступна."}</div>
`;
if (activeEdit === "usd") syncFromUsd();
else syncFromSol();
} catch (e) {
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
}
}
async function buyByUsd() {
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, queues, coef, pyth } = lastState || await loadCoreState();
if (queues.q1SumTotal >= coef.limitUsdCents) throw new Error("Покупка временно приостановлена: лимит заполнен.");
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
const payLamports = usdCentsToLamportsCeil(usdCents, pyth);
const maxPayLamports = applySlippageUp(payLamports, slippage);
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 data = ixData(IX.buyTicketUsd, u64ToBytes(usdCents), u64ToBytes(maxPayLamports), 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: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
{ 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>`;
}
}
async function buyBySol() {
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, queues, coef, pyth } = lastState || await loadCoreState();
if (queues.q1SumTotal >= coef.limitUsdCents) throw new Error("Покупка временно приостановлена: лимит заполнен.");
const lamports = solTextToLamports(document.getElementById("amountSol").value);
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
const usdCents = lamportsToUsdCentsFloor(lamports, pyth);
const minUsdCents = applySlippageDown(usdCents, slippage);
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 data = ixData(IX.buyTicketSol, u64ToBytes(lamports), u64ToBytes(minUsdCents), 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: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
{ 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("buyUsdBtn").addEventListener("click", buyByUsd);
document.getElementById("buySolBtn").addEventListener("click", buyBySol);
document.getElementById("amountUsd").addEventListener("input", () => { activeEdit = "usd"; syncFromUsd(); });
document.getElementById("amountSol").addEventListener("input", () => { activeEdit = "sol"; syncFromSol(); });
document.getElementById("slippagePct").addEventListener("input", renderQuote);
refreshState();
</script>
</body>
</html>