Files
SHiNE-server/shine-solana/shine/programs/shine_payments/web/manager_tools.html
T

386 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 Mainnet</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: 1800px; }
.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, select { padding: 9px 10px; min-width: 190px; 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; }
.err-box {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid #4c2730;
border-radius: 8px;
background: #23151a;
color: var(--err);
white-space: pre-wrap;
line-height: 1.45;
font-family: monospace;
font-size: 13px;
}
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>Менеджер: создание билетов (Mainnet)</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="limitsInfo" class="muted">Загрузка...</div>
</div>
<div class="panel">
<h3>Создать билет менеджером</h3>
<div class="row">
<label>Очередь:
<select id="queueId">
<option value="1">Очередь 1</option>
<option value="2">Очередь 2</option>
<option value="3">Очередь 3</option>
</select>
</label>
<label>Получатель: <input id="recipient" placeholder="Base58 адрес" /></label>
<label>Сумма выплаты (USD): <input id="payoutUsd" value="50" /></label>
</div>
<div class="row">
<button id="createBtn">Создать билет</button>
</div>
<div id="createResult" 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("SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v");
const RPC_URL = "https://solana-rpc.publicnode.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = {
managerAllowance: "shine_p_manager_allow",
queues: "shine_payments_queues",
ticketQ1: "shine_payments_q1_ticket",
ticketQ2: "shine_payments_q2_ticket",
ticketQ3: "shine_payments_q3_ticket",
};
const IX = { managerAddTicket: 7 };
let walletPubkey = null;
let queuesCache = 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 centsToUsdStr(c) {
return trimZeros((Number(c) / 100).toFixed(2));
}
function usdToCents(usdStr) {
const v = Number(usdStr.replace(",", "."));
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
return BigInt(Math.round(v * 100));
}
function ixData(tag, ...parts) {
return concat(new Uint8Array([tag]), ...parts);
}
function isManagerErrors(msg) {
const s = String(msg || "").toLowerCase();
return s.includes("managerlimitexceeded") || s.includes("invalidmanagerwallet");
}
function safeJson(value) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function formatSolanaErrorDetails(error) {
const parts = [];
const msg = String(error?.message || error || "").trim();
if (msg) parts.push(msg);
const logs = error?.logs || error?.transactionLogs || error?.simulationLogs || error?.data?.logs;
if (Array.isArray(logs) && logs.length) {
parts.push(`Logs:\n${logs.join("\n")}`);
}
const errObj = error?.value?.err || error?.err || error?.data?.err;
if (errObj) {
parts.push(`Err:\n${safeJson(errObj)}`);
}
if (error?.signature) {
parts.push(`Signature: ${error.signature}`);
}
return parts.length ? parts.join("\n\n") : "unknown";
}
async function attachSolanaLogs(error) {
if (!error) return error;
if (typeof error.getLogs === "function") {
try {
const logs = await error.getLogs(connection);
if (Array.isArray(logs) && logs.length) {
error.logs = logs;
error.transactionLogs = logs;
error.simulationLogs = logs;
}
} catch {
// Игнорируем: часть RPC не умеет отдавать логи для expired/confirm ошибок.
}
}
return error;
}
function parseManagerAllowance(data) {
let o = 0;
const version = data[o++];
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const q1 = readU64(data, o); o += 8;
const q2 = readU64(data, o); o += 8;
const q3 = readU64(data, o); o += 8;
return { version, manager, q1, q2, q3 };
}
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 out = document.getElementById("walletInfo");
try {
const provider = getProvider();
const r = await provider.connect();
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
out.textContent = "Кошелек менеджера: " + walletPubkey.toBase58();
await refresh();
} catch (e) {
out.innerHTML = `<span class="err">${String(e?.message || e)}</span>`;
throw e;
}
}
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 });
try {
await connection.confirmTransaction(
{ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight },
"confirmed",
);
return { signature: sig, confirmed: true };
} catch (confirmError) {
confirmError.signature = sig;
const txInfo = await connection.getTransaction(sig, {
commitment: "confirmed",
maxSupportedTransactionVersion: 0,
});
if (txInfo?.meta?.logMessages?.length) {
confirmError.logs = txInfo.meta.logMessages;
confirmError.transactionLogs = txInfo.meta.logMessages;
}
await attachSolanaLogs(confirmError);
const postCheck = await Promise.resolve(typeof ix._postCheck === "function" ? ix._postCheck(sig) : null);
if (postCheck?.success) {
return { signature: sig, confirmed: false, postCheck };
}
throw confirmError;
}
}
function deriveManagerAllowancePda(managerWallet) {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync(
[utf8(SEEDS.managerAllowance), managerWallet.toBytes()],
PROGRAM_ID
);
return pda;
}
function deriveQueuesPda() {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
return pda;
}
function deriveTicketPda(queueId, index) {
const seed = queueId === 1 ? SEEDS.ticketQ1 : (queueId === 2 ? SEEDS.ticketQ2 : SEEDS.ticketQ3);
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
return pda;
}
async function loadCore() {
if (!walletPubkey) throw new Error("Сначала подключите кошелек менеджера.");
const allowancePda = deriveManagerAllowancePda(walletPubkey);
const queuesPda = deriveQueuesPda();
const [allowanceAi, queuesAi] = await Promise.all([
connection.getAccountInfo(allowancePda, "confirmed"),
connection.getAccountInfo(queuesPda, "confirmed"),
]);
if (!queuesAi) throw new Error("Queues PDA не найден. Сначала выполните init.");
queuesCache = parseQueues(queuesAi.data);
return {
allowancePda,
allowance: allowanceAi ? parseManagerAllowance(allowanceAi.data) : null,
queuesPda,
queues: queuesCache,
};
}
async function refresh() {
const el = document.getElementById("limitsInfo");
try {
const core = await loadCore();
if (!core.allowance) {
el.innerHTML = `<span class="warn">Для этого кошелька лимиты менеджера пока не выданы.</span>`;
return;
}
el.innerHTML = `
<div>Manager: <code>${core.allowance.manager.toBase58()}</code></div>
<div>PDA лимитов: <code>${core.allowancePda.toBase58()}</code></div>
<div>Доступно Q1: <b>${centsToUsdStr(core.allowance.q1)} USD</b></div>
<div>Доступно Q2: <b>${centsToUsdStr(core.allowance.q2)} USD</b></div>
<div>Доступно Q3: <b>${centsToUsdStr(core.allowance.q3)} USD</b></div>
`;
} catch (e) {
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
async function createManagerTicket() {
const out = document.getElementById("createResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
else if (!provider.isConnected) await provider.connect();
const core = await loadCore();
if (!core.allowance) throw new Error("Для этого кошелька лимиты менеджера не выданы.");
const queueId = Number(document.getElementById("queueId").value);
if (![1, 2, 3].includes(queueId)) throw new Error("Очередь должна быть 1, 2 или 3");
const recipient = new solanaWeb3.PublicKey(document.getElementById("recipient").value.trim());
const payout = usdToCents(document.getElementById("payoutUsd").value.trim());
const nextIndex = queueId === 1
? (core.queues.q1Total + 1n)
: (queueId === 2 ? (core.queues.q2Total + 1n) : (core.queues.q3Total + 1n));
const ticketPda = deriveTicketPda(queueId, nextIndex);
const data = ixData(IX.managerAddTicket, new Uint8Array([queueId]), recipient.toBytes(), u64ToBytes(payout));
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.allowancePda, isSigner: false, isWritable: true },
{ pubkey: core.queuesPda, isSigner: false, isWritable: true },
{ pubkey: ticketPda, isSigner: false, isWritable: true },
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
ix._postCheck = async () => {
const ai = await connection.getAccountInfo(ticketPda, "confirmed");
if (!ai) return { success: false };
return { success: true, ticketPda: ticketPda.toBase58() };
};
const result = await sendInstruction(ix);
if (result.confirmed) {
out.innerHTML = `<span class="ok">Билет создан. Tx: <code>${result.signature}</code></span>`;
} else {
out.innerHTML = `<span class="warn">Транзакция подтверждалась с задержкой, но билет создан on-chain. Tx: <code>${result.signature}</code></span>`;
}
await refresh();
} catch (e) {
const raw = String(e.message || e);
if (isManagerErrors(raw)) {
out.innerHTML = `<span class="warn">Операция отклонена: лимит менеджера недостаточен или кошелек не имеет прав менеджера.</span>`;
return;
}
out.innerHTML = `<span class="err">Ошибка создания билета:</span><div class="err-box">${formatSolanaErrorDetails(e)}</div>`;
}
}
document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refresh);
document.getElementById("createBtn").addEventListener("click", createManagerTicket);
refresh();
</script>
</body>
</html>