277 lines
12 KiB
HTML
277 lines
12 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>DAO-права менеджеров — 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: 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 { padding: 9px 10px; min-width: 220px; 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>DAO: права менеджеров (Devnet)</h1>
|
||
<div class="muted">Программа: <code id="programId"></code></div>
|
||
|
||
<div class="panel">
|
||
<div class="warn">
|
||
Пока реального DAO-голосования нет: роль DAO выполняет тестовый кошелек
|
||
<code>FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P</code>.<br />
|
||
Позже это заменяется на вызов из настоящего DAO-казначейства/голосования.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="row">
|
||
<button id="connectBtn">Подключить кошелек</button>
|
||
<button id="refreshBtn">Обновить</button>
|
||
</div>
|
||
<div id="walletInfo" class="muted"></div>
|
||
<div id="daoInfo" class="muted"></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>Выдать/добавить лимиты менеджеру</h3>
|
||
<div class="row">
|
||
<label>Кошелек менеджера: <input id="managerWallet" placeholder="Base58" /></label>
|
||
<label>Добавить лимит Q1 (USD): <input id="addQ1" value="100" /></label>
|
||
<label>Добавить лимит Q2 (USD): <input id="addQ2" value="50" /></label>
|
||
<label>Добавить лимит Q3 (USD): <input id="addQ3" value="25" /></label>
|
||
</div>
|
||
<div class="row">
|
||
<button id="grantBtn">Выдать лимиты</button>
|
||
</div>
|
||
<div id="grantResult" class="muted"></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h3>Текущие лимиты менеджера</h3>
|
||
<div class="row">
|
||
<button id="loadManagerBtn">Показать лимиты</button>
|
||
</div>
|
||
<div id="managerState" 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 connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||
const SEEDS = {
|
||
config: "shine_payments_config",
|
||
managerAllowance: "shine_p_manager_allow",
|
||
};
|
||
const IX = { grantManagerLimits: 3 };
|
||
let walletPubkey = null;
|
||
let configCache = 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 isUnauthorizedDao(msg) {
|
||
const s = String(msg || "").toLowerCase();
|
||
return s.includes("unauthorizeddao");
|
||
}
|
||
|
||
function parseConfig(data) {
|
||
let o = 0;
|
||
const version = data[o++];
|
||
const dao = 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, inflow };
|
||
}
|
||
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 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 refresh();
|
||
}
|
||
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 deriveConfigPda() {
|
||
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
|
||
return pda;
|
||
}
|
||
function deriveManagerAllowancePda(managerWallet) {
|
||
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.managerAllowance), managerWallet.toBytes()], PROGRAM_ID);
|
||
return pda;
|
||
}
|
||
|
||
async function loadConfig() {
|
||
const configPda = deriveConfigPda();
|
||
const ai = await connection.getAccountInfo(configPda, "confirmed");
|
||
if (!ai) throw new Error("Config PDA не найден. Сначала выполните init.");
|
||
configCache = { configPda, config: parseConfig(ai.data) };
|
||
return configCache;
|
||
}
|
||
|
||
async function refresh() {
|
||
const el = document.getElementById("daoInfo");
|
||
try {
|
||
const { config } = await loadConfig();
|
||
el.innerHTML = `
|
||
<div>DAO-кошелек: <code>${config.dao.toBase58()}</code></div>
|
||
<div class="muted">Выдавать лимиты может только этот кошелек.</div>
|
||
`;
|
||
} catch (e) {
|
||
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||
}
|
||
}
|
||
|
||
async function grantLimits() {
|
||
const out = document.getElementById("grantResult");
|
||
out.textContent = "";
|
||
try {
|
||
const provider = getProvider();
|
||
if (!walletPubkey) await connectWallet();
|
||
else if (!provider.isConnected) await provider.connect();
|
||
|
||
const { configPda } = configCache || await loadConfig();
|
||
const manager = new solanaWeb3.PublicKey(document.getElementById("managerWallet").value.trim());
|
||
const addQ1 = usdToCents(document.getElementById("addQ1").value.trim());
|
||
const addQ2 = usdToCents(document.getElementById("addQ2").value.trim());
|
||
const addQ3 = usdToCents(document.getElementById("addQ3").value.trim());
|
||
if (addQ1 === 0n && addQ2 === 0n && addQ3 === 0n) throw new Error("Нужно указать сумму хотя бы для одной очереди.");
|
||
|
||
const allowancePda = deriveManagerAllowancePda(manager);
|
||
const data = ixData(IX.grantManagerLimits, manager.toBytes(), u64ToBytes(addQ1), u64ToBytes(addQ2), u64ToBytes(addQ3));
|
||
const keys = [
|
||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||
{ pubkey: configPda, isSigner: false, isWritable: true },
|
||
{ pubkey: allowancePda, 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>`;
|
||
} catch (e) {
|
||
const raw = String(e.message || e);
|
||
if (isUnauthorizedDao(raw)) {
|
||
const dao = configCache?.config?.dao?.toBase58?.() || "не определен";
|
||
out.innerHTML = `<span class="warn">Вы подключены не под DAO-кошельком. Нужен: <code>${dao}</code>.</span>`;
|
||
return;
|
||
}
|
||
out.innerHTML = `<span class="err">${raw}</span>`;
|
||
}
|
||
}
|
||
|
||
async function loadManagerLimits() {
|
||
const out = document.getElementById("managerState");
|
||
out.textContent = "";
|
||
try {
|
||
const manager = new solanaWeb3.PublicKey(document.getElementById("managerWallet").value.trim());
|
||
const allowancePda = deriveManagerAllowancePda(manager);
|
||
const ai = await connection.getAccountInfo(allowancePda, "confirmed");
|
||
if (!ai) {
|
||
out.innerHTML = `<span class="warn">Лимиты для этого менеджера ещё не выданы (PDA не создан).</span>`;
|
||
return;
|
||
}
|
||
const st = parseManagerAllowance(ai.data);
|
||
out.innerHTML = `
|
||
<div>Manager: <code>${st.manager.toBase58()}</code></div>
|
||
<div>PDA: <code>${allowancePda.toBase58()}</code></div>
|
||
<div>Доступно Q1: <b>${centsToUsdStr(st.q1)} USD</b></div>
|
||
<div>Доступно Q2: <b>${centsToUsdStr(st.q2)} USD</b></div>
|
||
<div>Доступно Q3: <b>${centsToUsdStr(st.q3)} USD</b></div>
|
||
`;
|
||
} catch (e) {
|
||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||
}
|
||
}
|
||
|
||
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||
document.getElementById("refreshBtn").addEventListener("click", refresh);
|
||
document.getElementById("grantBtn").addEventListener("click", grantLimits);
|
||
document.getElementById("loadManagerBtn").addEventListener("click", loadManagerLimits);
|
||
refresh();
|
||
</script>
|
||
</body>
|
||
</html>
|