SHA256
Отключить репосты и добавить Solana-модуль
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "shine_payments"
|
||||
version = "0.2.0"
|
||||
description = "Shine Payments v2 (очереди выплат)"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
name = "shine_payments"
|
||||
test = false
|
||||
doctest = false
|
||||
bench = false
|
||||
|
||||
[dependencies]
|
||||
anchor-lang = "0.31.1"
|
||||
common = { path = "../common" }
|
||||
pyth-solana-receiver-sdk = { path = "../../.vendor/pyth-crosschain/target_chains/solana/pyth_solana_receiver_sdk" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
no-entrypoint = []
|
||||
no-idl = []
|
||||
no-log-ix-name = []
|
||||
anchor-debug = []
|
||||
custom-heap = []
|
||||
custom-panic = []
|
||||
cpi = []
|
||||
idl-build = ["anchor-lang/idl-build"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
use common::deploy_config;
|
||||
|
||||
/// `CONFIG_SEED` — seed PDA основного конфига `shine_payments`.
|
||||
pub const CONFIG_SEED: &[u8] = b"shine_payments_config";
|
||||
/// `COEF_LIMIT_SEED` — seed PDA коэффициента, лимита и награды шага выплат.
|
||||
pub const COEF_LIMIT_SEED: &[u8] = b"shine_payments_coef_limit";
|
||||
/// `QUEUES_SEED` — seed PDA агрегатов очередей выплат.
|
||||
pub const QUEUES_SEED: &[u8] = b"shine_payments_queues";
|
||||
/// `INFLOW_VAULT_SEED` — seed PDA inflow-вольта, откуда исполняются выплаты.
|
||||
pub const INFLOW_VAULT_SEED: &[u8] = b"shine_payments_inflow_vault";
|
||||
/// `Q1_TICKET_SEED` — seed PDA тикетов очереди 1.
|
||||
pub const Q1_TICKET_SEED: &[u8] = b"shine_payments_q1_ticket";
|
||||
/// `Q2_TICKET_SEED` — seed PDA тикетов очереди 2.
|
||||
pub const Q2_TICKET_SEED: &[u8] = b"shine_payments_q2_ticket";
|
||||
/// `MANAGER_ALLOWANCE_SEED` — seed PDA лимитов менеджера.
|
||||
pub const MANAGER_ALLOWANCE_SEED: &[u8] = b"shine_p_manager_allow";
|
||||
|
||||
/// `CONFIG_SPACE` — размер (в байтах) PDA `ConfigState`.
|
||||
pub const CONFIG_SPACE: usize = 8 + 160;
|
||||
/// `COEF_LIMIT_SPACE` — размер (в байтах) PDA `CoefLimitState`.
|
||||
pub const COEF_LIMIT_SPACE: usize = 8 + 96;
|
||||
/// `QUEUES_SPACE` — размер (в байтах) PDA `QueuesState`.
|
||||
pub const QUEUES_SPACE: usize = 8 + 192;
|
||||
/// `INFLOW_VAULT_SPACE` — размер (в байтах) PDA `VaultState`.
|
||||
pub const INFLOW_VAULT_SPACE: usize = 8 + 32;
|
||||
/// `TICKET_SPACE` — размер (в байтах) PDA `TicketState`.
|
||||
pub const TICKET_SPACE: usize = 8 + 160;
|
||||
/// `MANAGER_ALLOWANCE_SPACE` — размер (в байтах) PDA `ManagerAllowanceState`.
|
||||
pub const MANAGER_ALLOWANCE_SPACE: usize = 8 + 128;
|
||||
|
||||
/// `COEF_SCALE_PPM` — масштаб fixed-point для коэффициента (ppm = parts per million).
|
||||
pub const COEF_SCALE_PPM: u64 = 1_000_000;
|
||||
/// `START_COEF_PPM` — стартовый коэффициент выплаты при инициализации (`5_000_000` = 5.0x).
|
||||
pub const START_COEF_PPM: u64 = 5_000_000;
|
||||
/// `START_LIMIT_USD_CENTS` — стартовый лимит Q1 в USD-центах (10_000 USD).
|
||||
pub const START_LIMIT_USD_CENTS: u64 = 10_000 * 100;
|
||||
/// `START_CALL_REWARD_LAMPORTS` — стартовая награда за вызов `step_payout` (0.008 SOL).
|
||||
pub const START_CALL_REWARD_LAMPORTS: u64 = 8_000_000;
|
||||
/// `MAX_CALL_REWARD_LAMPORTS` — верхняя граница награды за шаг выплат (0.01 SOL).
|
||||
pub const MAX_CALL_REWARD_LAMPORTS: u64 = 10_000_000;
|
||||
/// `USD_CENTS_SCALE` — масштаб USD-центов (1 USD = 100 центов).
|
||||
pub const USD_CENTS_SCALE: u64 = 100;
|
||||
/// `LAMPORTS_PER_SOL` — количество лампортов в 1 SOL.
|
||||
pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
|
||||
|
||||
/// `ORACLE_MAX_AGE_SECS` — максимальный возраст oracle-цены (в секундах), допустимый для расчетов.
|
||||
pub const ORACLE_MAX_AGE_SECS: u64 = 120;
|
||||
/// `PYTH_SOL_USD_FEED_ID` — feed id Pyth для пары SOL/USD (берется из общего deploy-конфига).
|
||||
pub const PYTH_SOL_USD_FEED_ID: &str = deploy_config::PYTH_SOL_USD_FEED_ID;
|
||||
/// `PYTH_SOL_USD_ACCOUNT` — адрес аккаунта Pyth price update для SOL/USD (берется из общего deploy-конфига).
|
||||
pub const PYTH_SOL_USD_ACCOUNT: &str = deploy_config::PYTH_SOL_USD_ACCOUNT;
|
||||
|
||||
/// `DAO_WALLET` — адрес кошелька DAO-казны для `shine_payments` (берется из общего deploy-конфига).
|
||||
pub const DAO_WALLET: &str = deploy_config::DAO_TREASURY_WALLET;
|
||||
@@ -0,0 +1,561 @@
|
||||
<!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: 1850px; }
|
||||
.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: 170px; 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; }
|
||||
.paid { color: var(--ok); font-weight: 700; }
|
||||
.formula { font-family: monospace; color: #c9d7f0; }
|
||||
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid var(--line); padding: 6px; text-align: left; font-size: 14px; vertical-align: top; }
|
||||
</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>
|
||||
<button id="initBtn">Init (один раз)</button>
|
||||
</div>
|
||||
<div id="walletInfo" class="muted"></div>
|
||||
<div id="initResult" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Коэффициент, лимит и награда шага выплат</h3>
|
||||
<div class="muted">Право изменения: <code id="daoAllowed">загрузка...</code></div>
|
||||
<div class="row">
|
||||
<label>Коэффициент (например 5): <input id="coefInput" value="5" /></label>
|
||||
<label>Лимит (USD): <input id="limitInput" value="10000" /></label>
|
||||
<label>Награда шага (SOL, max 0.01): <input id="rewardInput" value="0.008" /></label>
|
||||
<button id="updateCoefBtn">Обновить</button>
|
||||
</div>
|
||||
<div class="formula">Лимит покупки Q1 = max(limit_usd_cents - q1_sum_total_usd_cents, 0)</div>
|
||||
<div class="formula">Шаг выплаты Q1 = ticket + dao(1x) + reward; Q2 = ticket + dao(2x) + reward</div>
|
||||
<div id="updateResult" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Shine Users: экономические параметры</h3>
|
||||
<div class="muted">Право изменения: <code id="usersDaoAllowed">загрузка...</code></div>
|
||||
<div id="usersEconomyState" class="muted">Загрузка...</div>
|
||||
<div class="row">
|
||||
<label>Комиссия регистрации (SOL): <input id="usersRegFeeInput" value="0.01" /></label>
|
||||
<label>Цена шага лимита (SOL): <input id="usersLimitStepFeeInput" value="0.0001" /></label>
|
||||
<label>Стартовый бонус лимита: <input id="usersBonusInput" value="100000" /></label>
|
||||
<button id="usersUpdateBtn">Обновить</button>
|
||||
<button id="usersInitBtn">Init Users Economy</button>
|
||||
</div>
|
||||
<div id="usersUpdateResult" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Адреса и агрегаты</h3>
|
||||
<div id="balances" class="muted">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Очередь 1 (все билеты)</h3>
|
||||
<div class="row"><button id="loadQ1Btn">Показать очередь 1</button></div>
|
||||
<div id="queue1Table" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Очередь 2 (все билеты)</h3>
|
||||
<div class="row"><button id="loadQ2Btn">Показать очередь 2</button></div>
|
||||
<div id="queue2Table" 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 USERS_PROGRAM_ID = new solanaWeb3.PublicKey("FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm");
|
||||
const RPC_URL = "https://api.devnet.solana.com";
|
||||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||
const SEEDS = {
|
||||
config: "shine_payments_v3_config",
|
||||
coef: "shine_payments_v3_coef_limit",
|
||||
queues: "shine_payments_v3_queues",
|
||||
inflow: "shine_payments_v3_inflow_vault",
|
||||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||
ticketQ2: "shine_payments_v3_q2_ticket",
|
||||
};
|
||||
const USERS_SEEDS = {
|
||||
economyConfig: "shine_users_v1_economy_config",
|
||||
};
|
||||
const MAX_REWARD_LAMPORTS = 10_000_000n;
|
||||
let walletPubkey = null;
|
||||
let cache = 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 centsToUsdStr(c) {
|
||||
return trimZeros((Number(c) / 100).toFixed(2));
|
||||
}
|
||||
function solToLamports(solStr) {
|
||||
const v = Number(solStr.replace(",", "."));
|
||||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
|
||||
return BigInt(Math.round(v * 1_000_000_000));
|
||||
}
|
||||
function usdToCents(usdStr) {
|
||||
const v = Number(usdStr.replace(",", "."));
|
||||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
|
||||
return BigInt(Math.round(v * 100));
|
||||
}
|
||||
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 isUnauthorizedDao(msg) {
|
||||
const s = String(msg || "").toLowerCase();
|
||||
return s.includes("unauthorizeddao") || s.includes("0x1775");
|
||||
}
|
||||
function isUsersDaoUnauthorized(msg) {
|
||||
const s = String(msg || "").toLowerCase();
|
||||
return s.includes("invalidsigner") || s.includes("0x3ed");
|
||||
}
|
||||
|
||||
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 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;
|
||||
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
|
||||
}
|
||||
function parseTicket(data) {
|
||||
let o = 0;
|
||||
const version = data[o++];
|
||||
const queueId = data[o++];
|
||||
const index = readU64(data, o); o += 8;
|
||||
const isPaid = data[o++] === 1;
|
||||
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
|
||||
const payout = readU64(data, o); o += 8;
|
||||
const debtBefore = readU64(data, o); o += 8;
|
||||
return { version, queueId, index, isPaid, recipient, payout, debtBefore };
|
||||
}
|
||||
function parseUsersEconomyConfig(data) {
|
||||
let o = 0;
|
||||
const version = data[o++];
|
||||
const registrationFeeLamports = readU64(data, o); o += 8;
|
||||
const lamportsPerLimitStep = readU64(data, o); o += 8;
|
||||
const startBonusLimit = readU64(data, o); o += 8;
|
||||
return { version, registrationFeeLamports, lamportsPerLimitStep, startBonusLimit };
|
||||
}
|
||||
|
||||
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 refreshAll();
|
||||
}
|
||||
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);
|
||||
const [inflowPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.inflow)], PROGRAM_ID);
|
||||
return { configPda, coefPda, queuesPda, inflowPda };
|
||||
}
|
||||
function deriveUsersPdas() {
|
||||
const [usersEconomyConfigPda] = solanaWeb3.PublicKey.findProgramAddressSync(
|
||||
[utf8(USERS_SEEDS.economyConfig)],
|
||||
USERS_PROGRAM_ID
|
||||
);
|
||||
return { usersEconomyConfigPda };
|
||||
}
|
||||
function ticketPda(queueId, index) {
|
||||
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
|
||||
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
|
||||
return pda;
|
||||
}
|
||||
|
||||
async function loadCore() {
|
||||
const pdas = derivePdas();
|
||||
const [cfgAi, coefAi, qAi, inflowAi] = await Promise.all([
|
||||
connection.getAccountInfo(pdas.configPda, "confirmed"),
|
||||
connection.getAccountInfo(pdas.coefPda, "confirmed"),
|
||||
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
|
||||
connection.getAccountInfo(pdas.inflowPda, "confirmed"),
|
||||
]);
|
||||
if (!cfgAi || !coefAi || !qAi || !inflowAi) {
|
||||
cache = { pdas, notInited: true };
|
||||
return cache;
|
||||
}
|
||||
const config = parseConfig(cfgAi.data);
|
||||
const coef = parseCoef(coefAi.data);
|
||||
const queues = parseQueues(qAi.data);
|
||||
const [daoBal, inflowRent] = await Promise.all([
|
||||
connection.getBalance(config.dao, "confirmed"),
|
||||
connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed"),
|
||||
]);
|
||||
cache = {
|
||||
pdas, config, coef, queues,
|
||||
inflowLamports: BigInt(inflowAi.lamports),
|
||||
inflowAvailable: BigInt(Math.max(0, inflowAi.lamports - inflowRent)),
|
||||
daoBalance: BigInt(daoBal),
|
||||
};
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function refreshBalances() {
|
||||
const el = document.getElementById("balances");
|
||||
try {
|
||||
const core = await loadCore();
|
||||
if (core.notInited) {
|
||||
el.innerHTML = `<span class="warn">PDA ещё не инициализированы.</span>`;
|
||||
return;
|
||||
}
|
||||
const coefText = trimZeros((Number(core.coef.coefPpm) / 1_000_000).toFixed(6));
|
||||
const limitRemain = core.coef.limitUsdCents > core.queues.q1SumTotal ? (core.coef.limitUsdCents - core.queues.q1SumTotal) : 0n;
|
||||
document.getElementById("daoAllowed").textContent = core.config.dao.toBase58();
|
||||
el.innerHTML = `
|
||||
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
|
||||
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
|
||||
<div>Inflow vault: <code>${core.config.inflow.toBase58()}</code></div>
|
||||
<div class="muted">Inflow vault — входящий PDA-кошелек выплат программы.</div>
|
||||
<div>Награда за шаг: <b>${lamportsToSolStr(core.coef.reward)} SOL</b></div>
|
||||
<div>Коэффициент: <b>${coefText}</b>, лимит: <b>${centsToUsdStr(core.coef.limitUsdCents)} USD</b></div>
|
||||
<div>Осталось лимита для покупки Q1: <b>${centsToUsdStr(limitRemain)} USD</b></div>
|
||||
<div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div>
|
||||
<div>Баланс inflow vault: <b>${lamportsToSolStr(core.inflowLamports)} SOL</b></div>
|
||||
<div>Доступно в inflow (сверх ренты): <b>${lamportsToSolStr(core.inflowAvailable)} SOL</b></div>
|
||||
<div>Q1: total=${core.queues.q1Total}, paid=${core.queues.q1Paid}, sum_total=${centsToUsdStr(core.queues.q1SumTotal)} USD, sum_paid=${centsToUsdStr(core.queues.q1SumPaid)} USD</div>
|
||||
<div>Q2: total=${core.queues.q2Total}, paid=${core.queues.q2Paid}, sum_total=${centsToUsdStr(core.queues.q2SumTotal)} USD, sum_paid=${centsToUsdStr(core.queues.q2SumPaid)} USD</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
document.getElementById("daoAllowed").textContent = "не определен";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsersEconomy() {
|
||||
const out = document.getElementById("usersEconomyState");
|
||||
try {
|
||||
const usersPdas = deriveUsersPdas();
|
||||
const ai = await connection.getAccountInfo(usersPdas.usersEconomyConfigPda, "confirmed");
|
||||
document.getElementById("usersDaoAllowed").textContent = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P";
|
||||
if (!ai) {
|
||||
out.innerHTML = `<span class="warn">PDA Users Economy еще не инициализирован.</span><div>PDA: <code>${usersPdas.usersEconomyConfigPda.toBase58()}</code></div>`;
|
||||
return;
|
||||
}
|
||||
const c = parseUsersEconomyConfig(ai.data);
|
||||
document.getElementById("usersRegFeeInput").value = lamportsToSolStr(c.registrationFeeLamports);
|
||||
document.getElementById("usersLimitStepFeeInput").value = lamportsToSolStr(c.lamportsPerLimitStep);
|
||||
document.getElementById("usersBonusInput").value = c.startBonusLimit.toString();
|
||||
out.innerHTML = `
|
||||
<div>Users program: <code>${USERS_PROGRAM_ID.toBase58()}</code></div>
|
||||
<div>Economy config PDA: <code>${usersPdas.usersEconomyConfigPda.toBase58()}</code></div>
|
||||
<div>registration_fee_lamports: <b>${c.registrationFeeLamports.toString()}</b> (~${lamportsToSolStr(c.registrationFeeLamports)} SOL)</div>
|
||||
<div>lamports_per_limit_step: <b>${c.lamportsPerLimitStep.toString()}</b> (~${lamportsToSolStr(c.lamportsPerLimitStep)} SOL)</div>
|
||||
<div>start_bonus_limit: <b>${c.startBonusLimit.toString()}</b></div>
|
||||
`;
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runInit() {
|
||||
const out = document.getElementById("initResult");
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
|
||||
const pdas = derivePdas();
|
||||
const disc = await ixDiscriminator("init");
|
||||
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: pdas.inflowPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||
];
|
||||
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data: disc });
|
||||
const sig = await sendInstruction(ix);
|
||||
out.innerHTML = `<span class="ok">Init выполнен. Tx: <code>${sig}</code></span>`;
|
||||
await refreshAll();
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCoefLimit() {
|
||||
const out = document.getElementById("updateResult");
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
const core = await loadCore();
|
||||
if (core.notInited) throw new Error("Сначала выполните init");
|
||||
|
||||
const coef = Number(document.getElementById("coefInput").value.trim());
|
||||
if (!Number.isFinite(coef) || coef <= 0) throw new Error("Некорректный коэффициент");
|
||||
const coefPpm = BigInt(Math.round(coef * 1_000_000));
|
||||
const limitUsdCents = usdToCents(document.getElementById("limitInput").value.trim());
|
||||
const rewardLamports = solToLamports(document.getElementById("rewardInput").value.trim());
|
||||
if (rewardLamports > MAX_REWARD_LAMPORTS) throw new Error("Награда не должна быть больше 0.01 SOL");
|
||||
|
||||
const disc = await ixDiscriminator("update_coef_limit");
|
||||
const data = concat(disc, u64ToBytes(coefPpm), u64ToBytes(limitUsdCents), u64ToBytes(rewardLamports));
|
||||
const keys = [
|
||||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: core.pdas.coefPda, isSigner: false, isWritable: true },
|
||||
];
|
||||
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 refreshAll();
|
||||
} catch (e) {
|
||||
const raw = String(e.message || e);
|
||||
if (isUnauthorizedDao(raw)) {
|
||||
const dao = document.getElementById("daoAllowed").textContent;
|
||||
out.innerHTML = `<span class="warn">Вы подключены не под тем аккаунтом. Изменение доступно только DAO-кошельку: <code>${dao}</code>.</span>`;
|
||||
return;
|
||||
}
|
||||
out.innerHTML = `<span class="err">${raw}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function initUsersEconomy() {
|
||||
const out = document.getElementById("usersUpdateResult");
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
const usersPdas = deriveUsersPdas();
|
||||
const disc = await ixDiscriminator("init_users_economy_config");
|
||||
const keys = [
|
||||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||
{ pubkey: usersPdas.usersEconomyConfigPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||
];
|
||||
const ix = new solanaWeb3.TransactionInstruction({ programId: USERS_PROGRAM_ID, keys, data: disc });
|
||||
const sig = await sendInstruction(ix);
|
||||
out.innerHTML = `<span class="ok">Users Economy init выполнен. Tx: <code>${sig}</code></span>`;
|
||||
await refreshUsersEconomy();
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUsersEconomy() {
|
||||
const out = document.getElementById("usersUpdateResult");
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
const usersPdas = deriveUsersPdas();
|
||||
const registrationFeeLamports = solToLamports(document.getElementById("usersRegFeeInput").value.trim());
|
||||
const lamportsPerLimitStep = solToLamports(document.getElementById("usersLimitStepFeeInput").value.trim());
|
||||
const startBonusLimit = BigInt(document.getElementById("usersBonusInput").value.trim());
|
||||
if (startBonusLimit < 0n) throw new Error("Стартовый бонус не может быть отрицательным");
|
||||
|
||||
const disc = await ixDiscriminator("update_users_economy_config");
|
||||
const data = concat(
|
||||
disc,
|
||||
u64ToBytes(registrationFeeLamports),
|
||||
u64ToBytes(lamportsPerLimitStep),
|
||||
u64ToBytes(startBonusLimit)
|
||||
);
|
||||
const keys = [
|
||||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||
{ pubkey: usersPdas.usersEconomyConfigPda, isSigner: false, isWritable: true },
|
||||
];
|
||||
const ix = new solanaWeb3.TransactionInstruction({ programId: USERS_PROGRAM_ID, keys, data });
|
||||
const sig = await sendInstruction(ix);
|
||||
out.innerHTML = `<span class="ok">Users Economy обновлен. Tx: <code>${sig}</code></span>`;
|
||||
await refreshUsersEconomy();
|
||||
} catch (e) {
|
||||
const raw = String(e.message || e);
|
||||
if (isUsersDaoUnauthorized(raw)) {
|
||||
const dao = document.getElementById("usersDaoAllowed").textContent;
|
||||
out.innerHTML = `<span class="warn">Изменение доступно только DAO-кошельку: <code>${dao}</code>.</span>`;
|
||||
return;
|
||||
}
|
||||
out.innerHTML = `<span class="err">${raw}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function currentDebtBeforeTicket(ticket, queues) {
|
||||
if (ticket.isPaid) return 0n;
|
||||
const paidSum = ticket.queueId === 1 ? queues.q1SumPaid : queues.q2SumPaid;
|
||||
return ticket.debtBefore > paidSum ? (ticket.debtBefore - paidSum) : 0n;
|
||||
}
|
||||
|
||||
async function showQueue(queueId) {
|
||||
const out = document.getElementById(queueId === 1 ? "queue1Table" : "queue2Table");
|
||||
out.textContent = "Загрузка...";
|
||||
try {
|
||||
const core = await loadCore();
|
||||
if (core.notInited) throw new Error("Сначала выполните init");
|
||||
const total = queueId === 1 ? core.queues.q1Total : core.queues.q2Total;
|
||||
if (total === 0n) {
|
||||
out.innerHTML = `<span class="muted">Очередь ${queueId} пока пустая.</span>`;
|
||||
return;
|
||||
}
|
||||
const rows = [];
|
||||
for (let i = 1n; i <= total; i++) {
|
||||
const pda = ticketPda(queueId, i);
|
||||
const ai = await connection.getAccountInfo(pda, "confirmed");
|
||||
if (!ai) {
|
||||
rows.push(`<tr><td>${i.toString()}</td><td>${queueId}</td><td colspan="6" class="err">PDA не найден</td></tr>`);
|
||||
continue;
|
||||
}
|
||||
const t = parseTicket(ai.data);
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td>${t.index.toString()}</td>
|
||||
<td>${t.queueId}</td>
|
||||
<td>${t.isPaid ? '<span class="paid">выплачен</span>' : "ожидание"}</td>
|
||||
<td><code>${t.recipient.toBase58()}</code></td>
|
||||
<td>${centsToUsdStr(t.payout)} USD</td>
|
||||
<td>${centsToUsdStr(t.debtBefore)} USD</td>
|
||||
<td>${centsToUsdStr(currentDebtBeforeTicket(t, core.queues))} USD</td>
|
||||
<td><code>${pda.toBase58()}</code></td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
out.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Очередь</th>
|
||||
<th>Статус</th>
|
||||
<th>Получатель</th>
|
||||
<th>Сумма выплаты (USD)</th>
|
||||
<th>Очередь до него (от старта)</th>
|
||||
<th>Очередь до него (актуально)</th>
|
||||
<th>PDA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows.join("")}</tbody>
|
||||
</table>
|
||||
`;
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await refreshBalances();
|
||||
await refreshUsersEconomy();
|
||||
}
|
||||
|
||||
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||
document.getElementById("refreshBtn").addEventListener("click", refreshAll);
|
||||
document.getElementById("initBtn").addEventListener("click", runInit);
|
||||
document.getElementById("updateCoefBtn").addEventListener("click", updateCoefLimit);
|
||||
document.getElementById("usersInitBtn").addEventListener("click", initUsersEconomy);
|
||||
document.getElementById("usersUpdateBtn").addEventListener("click", updateUsersEconomy);
|
||||
document.getElementById("loadQ1Btn").addEventListener("click", () => showQueue(1));
|
||||
document.getElementById("loadQ2Btn").addEventListener("click", () => showQueue(2));
|
||||
refreshAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,428 @@
|
||||
<!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("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||
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_v3_config",
|
||||
coef: "shine_payments_v3_coef_limit",
|
||||
queues: "shine_payments_v3_queues",
|
||||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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;
|
||||
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, 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 disc = await ixDiscriminator("buy_ticket_usd");
|
||||
const data = concat(disc, 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 disc = await ixDiscriminator("buy_ticket_sol");
|
||||
const data = concat(disc, 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>
|
||||
@@ -0,0 +1,342 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DAO revoke vote — 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); }
|
||||
.wrap { width: 100%; max-width: 1200px; }
|
||||
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
|
||||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
||||
label { display: inline-flex; flex-direction: column; gap: 6px; color: var(--muted); min-width: 280px; }
|
||||
input { padding: 9px 10px; 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; }
|
||||
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div style="margin-bottom: 12px;"><a class="back" href="./index.html">← На главную</a></div>
|
||||
<h1>DAO: голосование на revoke/burn membership token (Devnet)</h1>
|
||||
<div class="muted">Governance program: <code id="govPid"></code></div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="row">
|
||||
<button id="connectBtn">Подключить Phantom</button>
|
||||
</div>
|
||||
<div id="walletInfo" class="muted">Кошелек: не подключен</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="row">
|
||||
<label>Realm
|
||||
<input id="realm" placeholder="Realm pubkey" />
|
||||
</label>
|
||||
<label>Governance
|
||||
<input id="governance" placeholder="Governance pubkey" />
|
||||
</label>
|
||||
<label>Community mint
|
||||
<input id="mint" placeholder="Mint pubkey" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Target owner
|
||||
<input id="targetOwner" placeholder="Кого лишаем governance token" />
|
||||
</label>
|
||||
<label>Amount
|
||||
<input id="amount" value="1" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="createVoteBtn">Create + SignOff + Vote</button>
|
||||
</div>
|
||||
<div id="proposalResult" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="row">
|
||||
<label>Proposal
|
||||
<input id="proposal" placeholder="Proposal pubkey" />
|
||||
</label>
|
||||
<label>Proposal transaction
|
||||
<input id="proposalTx" placeholder="ProposalTransaction pubkey" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="executeBtn">Execute revoke</button>
|
||||
</div>
|
||||
<div class="muted">Если получите hold-up (`0x20d`) — дождитесь конца voting window/hold-up и повторите execute.</div>
|
||||
<div id="executeResult" class="muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import BN from "https://esm.sh/bn.js@5.2.1";
|
||||
import {
|
||||
Connection,
|
||||
PublicKey,
|
||||
Transaction,
|
||||
clusterApiUrl
|
||||
} from "https://esm.sh/@solana/web3.js@1.95.3";
|
||||
import {
|
||||
PROGRAM_VERSION_V3,
|
||||
Vote,
|
||||
YesNoVote,
|
||||
VoteType,
|
||||
InstructionData,
|
||||
AccountMetaData,
|
||||
withRevokeGoverningTokens,
|
||||
withCreateProposal,
|
||||
withInsertTransaction,
|
||||
withSignOffProposal,
|
||||
withCastVote,
|
||||
withExecuteTransaction,
|
||||
getTokenOwnerRecordAddress
|
||||
} from "https://esm.sh/@solana/spl-governance@0.3.28";
|
||||
|
||||
const GOVERNANCE_PROGRAM_ID = new PublicKey("GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw");
|
||||
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
|
||||
document.getElementById("govPid").textContent = GOVERNANCE_PROGRAM_ID.toBase58();
|
||||
|
||||
let wallet = null;
|
||||
let walletPubkey = null;
|
||||
|
||||
function out(id, html, cls = "muted") {
|
||||
const el = document.getElementById(id);
|
||||
el.className = cls;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function mustPubkey(id) {
|
||||
const raw = document.getElementById(id).value.trim();
|
||||
if (!raw) throw new Error(`Пустое поле: ${id}`);
|
||||
return new PublicKey(raw);
|
||||
}
|
||||
|
||||
function toGovernanceInstructionData(ix) {
|
||||
return new InstructionData({
|
||||
programId: ix.programId,
|
||||
accounts: ix.keys.map(
|
||||
(k) => new AccountMetaData({
|
||||
pubkey: k.pubkey,
|
||||
isSigner: !!k.isSigner,
|
||||
isWritable: !!k.isWritable,
|
||||
})
|
||||
),
|
||||
data: Uint8Array.from(ix.data),
|
||||
});
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||||
wallet = window.solana;
|
||||
const res = await wallet.connect();
|
||||
walletPubkey = new PublicKey(res.publicKey.toString());
|
||||
out("walletInfo", `Кошелек: <code>${walletPubkey.toBase58()}</code>`, "muted");
|
||||
}
|
||||
|
||||
async function sendIxs(ixs) {
|
||||
if (!walletPubkey) await connect();
|
||||
const tx = new Transaction().add(...ixs);
|
||||
tx.feePayer = walletPubkey;
|
||||
const bh = await connection.getLatestBlockhash("confirmed");
|
||||
tx.recentBlockhash = bh.blockhash;
|
||||
const signed = await wallet.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 createSignVote() {
|
||||
out("proposalResult", "Выполняю...", "muted");
|
||||
try {
|
||||
const realm = mustPubkey("realm");
|
||||
const governance = mustPubkey("governance");
|
||||
const mint = mustPubkey("mint");
|
||||
const targetOwner = mustPubkey("targetOwner");
|
||||
const amount = new BN(document.getElementById("amount").value.trim() || "1");
|
||||
if (amount.lten(0)) throw new Error("Amount должен быть > 0");
|
||||
|
||||
const proposerRecord = await getTokenOwnerRecordAddress(
|
||||
GOVERNANCE_PROGRAM_ID,
|
||||
realm,
|
||||
mint,
|
||||
walletPubkey
|
||||
);
|
||||
|
||||
const proposalName = `Revoke ${amount.toString()} from ${targetOwner.toBase58().slice(0, 8)}...`;
|
||||
const proposalDescription = "https://arweave.net/";
|
||||
|
||||
const ixCreateProposal = [];
|
||||
const proposalPk = await withCreateProposal(
|
||||
ixCreateProposal,
|
||||
GOVERNANCE_PROGRAM_ID,
|
||||
PROGRAM_VERSION_V3,
|
||||
realm,
|
||||
governance,
|
||||
proposerRecord,
|
||||
proposalName,
|
||||
proposalDescription,
|
||||
mint,
|
||||
walletPubkey,
|
||||
undefined,
|
||||
VoteType.SINGLE_CHOICE,
|
||||
["Approve"],
|
||||
true,
|
||||
walletPubkey
|
||||
);
|
||||
const txCreateProposal = await sendIxs(ixCreateProposal);
|
||||
|
||||
const ixRawRevoke = [];
|
||||
await withRevokeGoverningTokens(
|
||||
ixRawRevoke,
|
||||
GOVERNANCE_PROGRAM_ID,
|
||||
PROGRAM_VERSION_V3,
|
||||
realm,
|
||||
targetOwner,
|
||||
mint,
|
||||
governance,
|
||||
amount
|
||||
);
|
||||
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
|
||||
|
||||
const ixInsert = [];
|
||||
const proposalTxPk = await withInsertTransaction(
|
||||
ixInsert,
|
||||
GOVERNANCE_PROGRAM_ID,
|
||||
PROGRAM_VERSION_V3,
|
||||
governance,
|
||||
proposalPk,
|
||||
proposerRecord,
|
||||
walletPubkey,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
[revokeInstructionData],
|
||||
walletPubkey
|
||||
);
|
||||
const txInsert = await sendIxs(ixInsert);
|
||||
|
||||
const ixSignOff = [];
|
||||
withSignOffProposal(
|
||||
ixSignOff,
|
||||
GOVERNANCE_PROGRAM_ID,
|
||||
PROGRAM_VERSION_V3,
|
||||
realm,
|
||||
governance,
|
||||
proposalPk,
|
||||
walletPubkey,
|
||||
undefined,
|
||||
proposerRecord
|
||||
);
|
||||
const txSignOff = await sendIxs(ixSignOff);
|
||||
|
||||
const ixVote = [];
|
||||
const vote = Vote.fromYesNoVote(YesNoVote.Yes);
|
||||
const voteRecordPk = await withCastVote(
|
||||
ixVote,
|
||||
GOVERNANCE_PROGRAM_ID,
|
||||
PROGRAM_VERSION_V3,
|
||||
realm,
|
||||
governance,
|
||||
proposalPk,
|
||||
proposerRecord,
|
||||
proposerRecord,
|
||||
walletPubkey,
|
||||
mint,
|
||||
vote,
|
||||
walletPubkey
|
||||
);
|
||||
const txVote = await sendIxs(ixVote);
|
||||
|
||||
document.getElementById("proposal").value = proposalPk.toBase58();
|
||||
document.getElementById("proposalTx").value = proposalTxPk.toBase58();
|
||||
|
||||
out(
|
||||
"proposalResult",
|
||||
`Proposal: <code>${proposalPk.toBase58()}</code><br/>` +
|
||||
`ProposalTx: <code>${proposalTxPk.toBase58()}</code><br/>` +
|
||||
`VoteRecord: <code>${voteRecordPk.toBase58()}</code><br/>` +
|
||||
`Tx create: <code>${txCreateProposal}</code><br/>` +
|
||||
`Tx insert: <code>${txInsert}</code><br/>` +
|
||||
`Tx signOff: <code>${txSignOff}</code><br/>` +
|
||||
`Tx vote: <code>${txVote}</code>`,
|
||||
"ok"
|
||||
);
|
||||
} catch (e) {
|
||||
out("proposalResult", String(e?.message || e), "err");
|
||||
}
|
||||
}
|
||||
|
||||
async function executeRevoke() {
|
||||
out("executeResult", "Выполняю execute...", "muted");
|
||||
try {
|
||||
const governance = mustPubkey("governance");
|
||||
const proposal = mustPubkey("proposal");
|
||||
const proposalTx = mustPubkey("proposalTx");
|
||||
const realm = mustPubkey("realm");
|
||||
const mint = mustPubkey("mint");
|
||||
const targetOwner = mustPubkey("targetOwner");
|
||||
const amount = new BN(document.getElementById("amount").value.trim() || "1");
|
||||
|
||||
const ixRawRevoke = [];
|
||||
await withRevokeGoverningTokens(
|
||||
ixRawRevoke,
|
||||
GOVERNANCE_PROGRAM_ID,
|
||||
PROGRAM_VERSION_V3,
|
||||
realm,
|
||||
targetOwner,
|
||||
mint,
|
||||
governance,
|
||||
amount
|
||||
);
|
||||
const revokeInstructionData = toGovernanceInstructionData(ixRawRevoke[0]);
|
||||
|
||||
const ixExecute = [];
|
||||
await withExecuteTransaction(
|
||||
ixExecute,
|
||||
GOVERNANCE_PROGRAM_ID,
|
||||
PROGRAM_VERSION_V3,
|
||||
governance,
|
||||
proposal,
|
||||
proposalTx,
|
||||
[revokeInstructionData]
|
||||
);
|
||||
const sig = await sendIxs(ixExecute);
|
||||
out("executeResult", `Execute success. Tx: <code>${sig}</code>`, "ok");
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || e);
|
||||
out("executeResult", msg, "err");
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("connectBtn").addEventListener("click", async () => {
|
||||
try { await connect(); } catch (e) { out("walletInfo", String(e?.message || e), "err"); }
|
||||
});
|
||||
document.getElementById("createVoteBtn").addEventListener("click", createSignVote);
|
||||
document.getElementById("executeBtn").addEventListener("click", executeRevoke);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!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>
|
||||
</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("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||
const RPC_URL = "https://api.devnet.solana.com";
|
||||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||
const SEEDS = {
|
||||
config: "shine_payments_v3_config",
|
||||
managerAllowance: "shine_p_v3_manager_allow",
|
||||
};
|
||||
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));
|
||||
}
|
||||
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 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;
|
||||
return { version, manager, q1, q2 };
|
||||
}
|
||||
|
||||
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());
|
||||
if (addQ1 === 0n && addQ2 === 0n) throw new Error("Нужно указать сумму хотя бы для одной очереди.");
|
||||
|
||||
const allowancePda = deriveManagerAllowancePda(manager);
|
||||
const disc = await ixDiscriminator("grant_manager_limits");
|
||||
const data = concat(disc, manager.toBytes(), u64ToBytes(addQ1), u64ToBytes(addQ2));
|
||||
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>
|
||||
`;
|
||||
} 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>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!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;
|
||||
--hover: #1f2634;
|
||||
--code: #1e2633;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; margin: 20px; background: var(--bg); color: var(--text); }
|
||||
.wrap { width: 100%; max-width: 1800px; }
|
||||
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
|
||||
a.card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--panel);
|
||||
}
|
||||
a.card:hover { background: var(--hover); }
|
||||
.muted { color: var(--muted); }
|
||||
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Shine Payments Devnet</h1>
|
||||
<div class="panel">
|
||||
<div>Выберите страницу:</div>
|
||||
</div>
|
||||
|
||||
<a class="card" href="./buy_ticket.html">
|
||||
<h3>Покупка билета</h3>
|
||||
<div class="muted">Создание нового билета в 1-й очереди: ввод в USD или SOL, хранение в USD-центах.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./track_ticket.html">
|
||||
<h3>Отслеживание билета</h3>
|
||||
<div class="muted">Проверка позиции, статуса и шага выплат с SOL/USD курсом Pyth.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./admin_tools.html">
|
||||
<h3>Тех. инструменты</h3>
|
||||
<div class="muted">Init, просмотр всех билетов, коэффициент/лимит в USD, награда шага в SOL.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./dao_tools.html">
|
||||
<h3>DAO-права менеджеров</h3>
|
||||
<div class="muted">Выдача лимитов менеджерам в USD для добавления билетов в очередь 1/2.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./dao_revoke_vote.html">
|
||||
<h3>DAO revoke governance token</h3>
|
||||
<div class="muted">UI для proposal/vote/execute на отзыв (burn/revoke) membership governance токенов.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./manager_tools.html">
|
||||
<h3>Инструменты менеджера</h3>
|
||||
<div class="muted">Показ лимитов менеджера и создание билетов в очередь 1/2 в USD.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./logic_overview.html">
|
||||
<h3>Логика работы</h3>
|
||||
<div class="muted">Кратко: как работают очереди, выплаты, лимиты и тестовый режим.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./roadmap_dao.html">
|
||||
<h3>Что ещё нужно до реального DAO</h3>
|
||||
<div class="muted">Ограничения тестовой версии и шаги к production.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./test_plan.html">
|
||||
<h3>Сценарий тестирования</h3>
|
||||
<div class="muted">Пошаговая методика тестов и возврата средств после теста.</div>
|
||||
</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!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;
|
||||
--code: #1e2633;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; margin: 20px; max-width: 1800px; line-height: 1.45; background: var(--bg); color: var(--text); }
|
||||
.topbar { margin-bottom: 12px; }
|
||||
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
|
||||
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
|
||||
.muted { color: var(--muted); }
|
||||
code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar"><a class="back" href="./index.html">← На главную</a></div>
|
||||
<h1>Логика работы Shine Payments (тестовый этап)</h1>
|
||||
<div class="panel">
|
||||
<p>Система работает в <b>Devnet</b>. Экономика хранится в <b>USD-центах</b>, а реальные переводы происходят в SOL.</p>
|
||||
<p>Курс SOL/USD берётся из Pyth прямо в контракте при покупке и при шаге выплаты. Цена проверяется на актуальность (не старше 120 секунд).</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>1. Очереди и билеты</h3>
|
||||
<p>Есть две очереди: очередь 1 и очередь 2. Каждый билет — отдельный PDA с полями: очередь, индекс, получатель, сумма выплаты, флаг выплачен/нет, сумма долга перед билетом.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>2. Покупка билета</h3>
|
||||
<p>Обычная покупка создаёт билет только в очереди 1. Пользователь может ввести сумму в USD или SOL на UI. В контракте сумма переводится по курсу в USD-центы, а выплата билета рассчитывается как <code>purchase_usd_cents * coef</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>3. Менеджерские билеты</h3>
|
||||
<p>DAO может выдать менеджеру лимиты на добавление билетов отдельно в очередь 1 и очередь 2. Менеджер создаёт билеты без денежного перевода, но с уменьшением своего доступного лимита.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>4. Порядок выплат</h3>
|
||||
<p>Приоритет всегда у очереди 1. Если в очереди 1 нет невыплаченных билетов, идут выплаты очереди 2. Если в процессе выплат очереди 2 снова появляется билет в очереди 1, приоритет возвращается к очереди 1.</p>
|
||||
<p>Шаг выплаты: для очереди 1 в DAO уходит 1x от выплаты тикета, для очереди 2 в DAO уходит 2x от выплаты тикета. Дополнительно вызывающий получает награду в SOL.</p>
|
||||
<p>Если обе очереди пустые/выплачены — доступный остаток inflow-вольта переводится в DAO (без награды вызывающему).</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>5. Тестовый режим пополнения выплат</h3>
|
||||
<p>Пока регистрация/авто-поток пополнения не завершены, inflow-вольт для выплат пополняется вручную, после чего выполняются шаги выплат.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel muted">
|
||||
Подробная версия в документе репозитория: <code>shine/doc/SHINE_PAYMENTS_V2.md</code>.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,280 @@
|
||||
<!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: 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; }
|
||||
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>Лимиты менеджера</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>
|
||||
</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("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||
const RPC_URL = "https://api.devnet.solana.com";
|
||||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||
const SEEDS = {
|
||||
managerAllowance: "shine_p_v3_manager_allow",
|
||||
queues: "shine_payments_v3_queues",
|
||||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||
ticketQ2: "shine_payments_v3_q2_ticket",
|
||||
};
|
||||
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));
|
||||
}
|
||||
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 isManagerErrors(msg) {
|
||||
const s = String(msg || "").toLowerCase();
|
||||
return s.includes("managerlimitexceeded") || s.includes("invalidmanagerwallet");
|
||||
}
|
||||
|
||||
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;
|
||||
return { version, manager, q1, q2 };
|
||||
}
|
||||
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 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 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 : SEEDS.ticketQ2;
|
||||
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>
|
||||
`;
|
||||
} 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 (queueId !== 1 && queueId !== 2) throw new Error("Очередь должна быть 1 или 2");
|
||||
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) : (core.queues.q2Total + 1n);
|
||||
const ticketPda = deriveTicketPda(queueId, nextIndex);
|
||||
|
||||
const disc = await ixDiscriminator("manager_add_ticket");
|
||||
const data = concat(disc, 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 });
|
||||
const sig = await sendInstruction(ix);
|
||||
out.innerHTML = `<span class="ok">Билет создан. Tx: <code>${sig}</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">${raw}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||
document.getElementById("refreshBtn").addEventListener("click", refresh);
|
||||
document.getElementById("createBtn").addEventListener("click", createManagerTicket);
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!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;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; margin: 20px; max-width: 1800px; line-height: 1.45; background: var(--bg); color: var(--text); }
|
||||
.topbar { margin-bottom: 12px; }
|
||||
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
|
||||
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar"><a class="back" href="./index.html">← На главную</a></div>
|
||||
<h1>Что ещё нужно до реального DAO</h1>
|
||||
|
||||
<div class="panel">
|
||||
<p>Сейчас роль DAO выполняет обычный кошелёк (тестовый режим Devnet).</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Что нужно добавить в production:</h3>
|
||||
<ol>
|
||||
<li>Заменить обычный DAO-кошелёк на DAO-казначейство/голосование.</li>
|
||||
<li>DAO-решения (выдача лимитов менеджерам, изменение параметров) проводить через голосование.</li>
|
||||
<li>Зафиксировать production-источник цены (oracle governance, fallback-политика, мониторинг stale-данных).</li>
|
||||
<li>Ограничить тестовые ключи и закрыть доступ к приватным данным.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<p>Текущий дизайн уже совместим с DAO-заменой: достаточно сменить авторизацию вызовов на DAO-механику без изменения базовой структуры очередей и билетов.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!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;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; margin: 20px; max-width: 1800px; line-height: 1.45; background: var(--bg); color: var(--text); }
|
||||
.topbar { margin-bottom: 12px; }
|
||||
.back { color: var(--muted); text-decoration: none; font-size: 18px; }
|
||||
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar"><a class="back" href="./index.html">← На главную</a></div>
|
||||
<h1>Сценарий тестирования Shine Payments (Devnet)</h1>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Вариант А: один кошелёк</h3>
|
||||
<ol>
|
||||
<li>Открыть <code>admin_tools</code>, выполнить <code>init</code>.</li>
|
||||
<li>Открыть <code>buy_ticket</code>, купить несколько билетов (часть через USD, часть через SOL).</li>
|
||||
<li>Открыть <code>dao_tools</code>, выдать лимиты менеджеру (тем же кошельком).</li>
|
||||
<li>Открыть <code>manager_tools</code>, создать билеты в очередь 1 и очередь 2.</li>
|
||||
<li>Пополнить inflow-вольт вручную.</li>
|
||||
<li>Открыть <code>track_ticket</code>, выполнять шаги выплат до погашения очередей.</li>
|
||||
<li>Проверить, что в шагах: Q1 = ticket + DAO(1x) + reward, Q2 = ticket + DAO(2x) + reward.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Вариант Б: несколько кошельков</h3>
|
||||
<ol>
|
||||
<li>Кошелёк 1: DAO (выдаёт лимиты менеджерам).</li>
|
||||
<li>Кошелёк 2: менеджер (создаёт билеты в очередь 1/2).</li>
|
||||
<li>Кошельки 3+: покупатели (создают обычные билеты через покупку).</li>
|
||||
<li>Любой кошелёк может запускать шаг выплат.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Как вернуть средства после тестов</h3>
|
||||
<ol>
|
||||
<li>Довести выплаты до нужного состояния (или остановить на текущем шаге).</li>
|
||||
<li>Сделать переводы с тестовых кошельков обратно на исходный кошелёк.</li>
|
||||
<li>При необходимости закрыть неиспользуемые program/PDA-аккаунты и вернуть ренту (через CLI).</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<p>Пока DAO-governance не подключена, ключевые действия DAO выполняются обычным тестовым кошельком. В production это заменяется голосованием DAO.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,506 @@
|
||||
<!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: 1850px; }
|
||||
.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; }
|
||||
.paid { color: var(--ok); font-weight: 700; }
|
||||
.waiting { color: var(--muted); }
|
||||
.xfer { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
|
||||
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>Поиск билетов</h3>
|
||||
<div class="row">
|
||||
<label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label>
|
||||
<label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label>
|
||||
<button id="findBtn">Найти</button>
|
||||
</div>
|
||||
<div id="ticketResult" class="muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Состояние шага выплат</h3>
|
||||
<div id="payoutInfo" class="muted">Загрузка...</div>
|
||||
<div class="row">
|
||||
<button id="stepBtn">Сделать шаг выплат</button>
|
||||
</div>
|
||||
<div id="stepResult" class="muted"></div>
|
||||
<div class="warn">Вызывающий шаг выплат платит сетевую комиссию транзакции и получает on-chain награду. Идея в том, что награда делает вызов экономически выгодным, поэтому всегда есть мотивация нажимать кнопку шага выплат.</div>
|
||||
<div class="muted">Автоматического таймера в контракте нет: в Solana любая инструкция должна быть инициирована внешним вызовом.</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 ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
|
||||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||
|
||||
const SEEDS = {
|
||||
config: "shine_payments_v3_config",
|
||||
coef: "shine_payments_v3_coef_limit",
|
||||
queues: "shine_payments_v3_queues",
|
||||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||
ticketQ2: "shine_payments_v3_q2_ticket",
|
||||
};
|
||||
|
||||
const LAMPORTS_PER_SOL = 1_000_000_000n;
|
||||
let walletPubkey = null;
|
||||
let cachedCore = 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 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(c) {
|
||||
return trimZeros((Number(c) / 100).toFixed(2));
|
||||
}
|
||||
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 isNotEnoughForStep(msg) {
|
||||
const s = String(msg || "").toLowerCase();
|
||||
return s.includes("notenoughinflowforstep") || s.includes("0x177a");
|
||||
}
|
||||
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 usdCentsToLamportsCeil(usdCents, px) {
|
||||
const n = usdCents * LAMPORTS_PER_SOL * px.den;
|
||||
return (n + px.num - 1n) / px.num;
|
||||
}
|
||||
function usdCentsToSolStr(usdCents, px) {
|
||||
return lamportsToSolStr(usdCentsToLamportsCeil(usdCents, px));
|
||||
}
|
||||
|
||||
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 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;
|
||||
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
|
||||
}
|
||||
function parseTicket(data) {
|
||||
let o = 0;
|
||||
const version = data[o++];
|
||||
const queueId = data[o++];
|
||||
const index = readU64(data, o); o += 8;
|
||||
const isPaid = data[o++] === 1;
|
||||
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
|
||||
const payoutUsdCents = readU64(data, o); o += 8;
|
||||
const debtBeforeUsdCents = readU64(data, o); o += 8;
|
||||
return { version, queueId, index, isPaid, recipient, payoutUsdCents, debtBeforeUsdCents };
|
||||
}
|
||||
|
||||
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 refreshAll();
|
||||
}
|
||||
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 deriveCorePdas() {
|
||||
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 };
|
||||
}
|
||||
function deriveTicketPda(queueId, index) {
|
||||
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
|
||||
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
|
||||
return pda;
|
||||
}
|
||||
|
||||
async function loadCoreState() {
|
||||
const pdas = deriveCorePdas();
|
||||
const [cfgAi, coefAi, qAi, 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 || !qAi) 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(qAi.data);
|
||||
const pyth = parsePythPriceUpdateV2(oracleAi.data);
|
||||
const inflowAi = await connection.getAccountInfo(config.inflow, "confirmed");
|
||||
if (!inflowAi) throw new Error("Inflow vault отсутствует");
|
||||
const rentMin = await connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed");
|
||||
const available = BigInt(Math.max(0, inflowAi.lamports - rentMin));
|
||||
cachedCore = { pdas, config, coef, queues, pyth, available };
|
||||
return cachedCore;
|
||||
}
|
||||
|
||||
function nextStepQueue(queues) {
|
||||
const q1Pending = queues.q1Total - queues.q1Paid;
|
||||
const q2Pending = queues.q2Total - queues.q2Paid;
|
||||
if (q1Pending > 0n) return 1;
|
||||
if (q2Pending > 0n) return 2;
|
||||
return 0;
|
||||
}
|
||||
function nextPayoutTicket(queues) {
|
||||
const queue = nextStepQueue(queues);
|
||||
if (queue === 0) return null;
|
||||
const index = queue === 1 ? (queues.q1Paid + 1n) : (queues.q2Paid + 1n);
|
||||
return { queue, index };
|
||||
}
|
||||
|
||||
async function refreshPayoutInfo() {
|
||||
const el = document.getElementById("payoutInfo");
|
||||
try {
|
||||
const core = await loadCoreState();
|
||||
const queue = nextStepQueue(core.queues);
|
||||
const pythAge = Math.max(0, Math.floor(Date.now() / 1000 - Number(core.pyth.publishTime)));
|
||||
if (queue === 0) {
|
||||
el.innerHTML = `
|
||||
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(core.pyth.num) / Number(core.pyth.den) / 100).toFixed(6))}</b>, возраст <b>${pythAge} сек</b></div>
|
||||
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
|
||||
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
|
||||
<div>Обе очереди пусты/полностью выплачены.</div>
|
||||
<div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
|
||||
<div class="warn">При шаге эта сумма уйдет в DAO, награда не начисляется.</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
|
||||
const nextPda = deriveTicketPda(queue, nextIndex);
|
||||
const nextAi = await connection.getAccountInfo(nextPda, "confirmed");
|
||||
if (!nextAi) {
|
||||
el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()} для очереди ${queue}</div>`;
|
||||
return;
|
||||
}
|
||||
const next = parseTicket(nextAi.data);
|
||||
const ticketLamports = usdCentsToLamportsCeil(next.payoutUsdCents, core.pyth);
|
||||
const daoUsd = queue === 1 ? next.payoutUsdCents : (next.payoutUsdCents * 2n);
|
||||
const daoLamports = usdCentsToLamportsCeil(daoUsd, core.pyth);
|
||||
const need = ticketLamports + daoLamports + core.coef.reward;
|
||||
const missing = core.available >= need ? 0n : (need - core.available);
|
||||
el.innerHTML = `
|
||||
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(core.pyth.num) / Number(core.pyth.den) / 100).toFixed(6))}</b>, возраст <b>${pythAge} сек</b></div>
|
||||
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
|
||||
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
|
||||
<div>Следующий шаг выплат: <b>очередь ${queue}</b></div>
|
||||
<div>Следующий тикет: <b>#${next.index.toString()}</b></div>
|
||||
<div>Тикет: <b>${centsToUsdStr(next.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(next.payoutUsdCents, core.pyth)} SOL)</div>
|
||||
<div>DAO на этом шаге: <b>${centsToUsdStr(daoUsd)} USD</b> (~${lamportsToSolStr(daoLamports)} SOL)</div>
|
||||
<div>Награда за шаг: <b>${lamportsToSolStr(core.coef.reward)} SOL</b></div>
|
||||
<div>Нужно для шага: <b>${lamportsToSolStr(need)} SOL</b></div>
|
||||
<div>Формула: <b>${queue === 1 ? "ticket + dao(1x) + reward" : "ticket + dao(2x) + reward"}</b></div>
|
||||
<div>Доступно в inflow vault: <b>${lamportsToSolStr(core.available)} SOL</b></div>
|
||||
<div>${missing === 0n
|
||||
? '<span class="ok">Хватает для шага выплаты.</span>'
|
||||
: `<span class="warn">Не хватает: ${lamportsToSolStr(missing)} SOL</span>`
|
||||
}</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTicketCard(core, pda, t) {
|
||||
const next = nextPayoutTicket(core.queues);
|
||||
const isNext = !!next && next.queue === t.queueId && next.index === t.index;
|
||||
const isOwner = walletPubkey && walletPubkey.toBase58() === t.recipient.toBase58();
|
||||
const canTransfer = !t.isPaid && isOwner && !isNext;
|
||||
const whyBlocked = t.isPaid
|
||||
? "Тикет уже выплачен"
|
||||
: !isOwner
|
||||
? "Передача доступна только текущему получателю тикета"
|
||||
: isNext
|
||||
? "Это следующий тикет на выплату, передача заблокирована"
|
||||
: "";
|
||||
return `
|
||||
<div class="panel">
|
||||
<div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
|
||||
<div>PDA: <code>${pda.toBase58()}</code></div>
|
||||
<div>Получатель: <code>${t.recipient.toBase58()}</code></div>
|
||||
<div>Сумма выплаты: <b>${centsToUsdStr(t.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(t.payoutUsdCents, core.pyth)} SOL)</div>
|
||||
<div>Изначально очередь перед тикетом: <b>${centsToUsdStr(t.debtBeforeUsdCents)} USD</b></div>
|
||||
<div class="xfer">
|
||||
<div><b>Передача билета</b></div>
|
||||
<div class="row">
|
||||
<input id="newRecipient_${t.queueId}_${t.index.toString()}" placeholder="Новый получатель (Base58)" />
|
||||
<button
|
||||
class="transferBtn"
|
||||
data-queue="${t.queueId}"
|
||||
data-index="${t.index.toString()}"
|
||||
data-pda="${pda.toBase58()}"
|
||||
${canTransfer ? "" : "disabled"}
|
||||
>Передать</button>
|
||||
</div>
|
||||
<div id="transferResult_${t.queueId}_${t.index.toString()}" class="${canTransfer ? "muted" : "warn"}">${canTransfer ? "Доступно для текущего владельца тикета." : whyBlocked}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function changeTicketRecipient(queueId, index, ticketPdaBase58) {
|
||||
const resultEl = document.getElementById(`transferResult_${queueId}_${index}`);
|
||||
const inputEl = document.getElementById(`newRecipient_${queueId}_${index}`);
|
||||
resultEl.className = "muted";
|
||||
resultEl.textContent = "";
|
||||
try {
|
||||
if (!walletPubkey) await connectWallet();
|
||||
const newRecipientRaw = (inputEl.value || "").trim();
|
||||
if (!newRecipientRaw) throw new Error("Введите адрес нового получателя");
|
||||
const newRecipient = new solanaWeb3.PublicKey(newRecipientRaw);
|
||||
|
||||
const core = cachedCore || await loadCoreState();
|
||||
const disc = await ixDiscriminator("change_ticket_recipient");
|
||||
const data = concat(disc, newRecipient.toBytes());
|
||||
const keys = [
|
||||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: new solanaWeb3.PublicKey(ticketPdaBase58), isSigner: false, isWritable: true },
|
||||
];
|
||||
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
|
||||
const sig = await sendInstruction(ix);
|
||||
resultEl.className = "ok";
|
||||
resultEl.innerHTML = `Передача выполнена. Tx: <code>${sig}</code>`;
|
||||
await refreshAll();
|
||||
await findTickets();
|
||||
} catch (e) {
|
||||
resultEl.className = "err";
|
||||
resultEl.textContent = String(e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async function findTickets() {
|
||||
const out = document.getElementById("ticketResult");
|
||||
out.textContent = "";
|
||||
try {
|
||||
const core = await loadCoreState();
|
||||
const idxRaw = document.getElementById("ticketIndex").value.trim();
|
||||
const walletRaw = document.getElementById("recipientWallet").value.trim();
|
||||
const results = [];
|
||||
|
||||
if (idxRaw) {
|
||||
const idx = BigInt(idxRaw);
|
||||
for (const queue of [1, 2]) {
|
||||
const pda = deriveTicketPda(queue, idx);
|
||||
const ai = await connection.getAccountInfo(pda, "confirmed");
|
||||
if (!ai) continue;
|
||||
results.push({ pda, t: parseTicket(ai.data) });
|
||||
}
|
||||
if (results.length === 0) throw new Error(`Тикет #${idx.toString()} не найден ни в одной очереди`);
|
||||
} else if (walletRaw) {
|
||||
const recipient = new solanaWeb3.PublicKey(walletRaw);
|
||||
for (const queue of [1, 2]) {
|
||||
const total = queue === 1 ? core.queues.q1Total : core.queues.q2Total;
|
||||
for (let i = 1n; i <= total; i++) {
|
||||
const pda = deriveTicketPda(queue, i);
|
||||
const ai = await connection.getAccountInfo(pda, "confirmed");
|
||||
if (!ai) continue;
|
||||
const t = parseTicket(ai.data);
|
||||
if (t.recipient.toBase58() === recipient.toBase58()) results.push({ pda, t });
|
||||
}
|
||||
}
|
||||
if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены");
|
||||
} else {
|
||||
throw new Error("Введите номер билета или кошелек получателя");
|
||||
}
|
||||
out.innerHTML = results.map(({ pda, t }) => renderTicketCard(core, pda, t)).join("");
|
||||
} catch (e) {
|
||||
out.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function stepPayout() {
|
||||
const out = document.getElementById("stepResult");
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
|
||||
const core = cachedCore || await loadCoreState();
|
||||
const queue = nextStepQueue(core.queues);
|
||||
|
||||
let nextTicketPda;
|
||||
let recipient;
|
||||
if (queue === 0) {
|
||||
nextTicketPda = deriveTicketPda(1, core.queues.q1Paid + 1n);
|
||||
recipient = walletPubkey;
|
||||
} else {
|
||||
const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
|
||||
nextTicketPda = deriveTicketPda(queue, nextIndex);
|
||||
const ai = await connection.getAccountInfo(nextTicketPda, "confirmed");
|
||||
if (!ai) throw new Error(`Следующий тикет #${nextIndex.toString()} для очереди ${queue} не найден`);
|
||||
recipient = parseTicket(ai.data).recipient;
|
||||
}
|
||||
|
||||
const disc = await ixDiscriminator("step_payout");
|
||||
const data = concat(disc);
|
||||
const keys = [
|
||||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: core.pdas.coefPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: core.config.inflow, isSigner: false, isWritable: true },
|
||||
{ pubkey: nextTicketPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: recipient, isSigner: false, isWritable: true },
|
||||
{ pubkey: core.config.dao, isSigner: false, isWritable: true },
|
||||
{ pubkey: ORACLE_ACCOUNT, 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 refreshAll();
|
||||
} catch (e) {
|
||||
const raw = String(e.message || e);
|
||||
if (isNotEnoughForStep(raw)) {
|
||||
out.innerHTML = `<span class="warn">Недостаточно средств для шага выплаты. Это нормальная обработанная ошибка.</span>`;
|
||||
return;
|
||||
}
|
||||
out.innerHTML = `<span class="err">${raw}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await refreshPayoutInfo();
|
||||
}
|
||||
|
||||
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||
document.getElementById("refreshBtn").addEventListener("click", refreshAll);
|
||||
document.getElementById("findBtn").addEventListener("click", findTickets);
|
||||
document.getElementById("stepBtn").addEventListener("click", stepPayout);
|
||||
document.getElementById("ticketResult").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".transferBtn");
|
||||
if (!btn) return;
|
||||
const queueId = Number(btn.dataset.queue);
|
||||
const index = btn.dataset.index;
|
||||
const pda = btn.dataset.pda;
|
||||
changeTicketRecipient(queueId, index, pda);
|
||||
});
|
||||
refreshAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user