Добавить раздел поддержки проекта Сияние
This commit is contained in:
parent
93c6f247f7
commit
3068c3e2b8
@ -0,0 +1,38 @@
|
|||||||
|
# Поддержать проект Сияние
|
||||||
|
|
||||||
|
Статус: `pending`
|
||||||
|
|
||||||
|
## Кратко
|
||||||
|
|
||||||
|
В `shine-UI/js/pages/wallet-view.js` добавлен новый раздел `Поддержать проект Сияние` с тремя входами:
|
||||||
|
|
||||||
|
1. купить билет;
|
||||||
|
2. посмотреть билет по номеру;
|
||||||
|
3. сгенерировать новую пару ключей.
|
||||||
|
|
||||||
|
## Что проверить
|
||||||
|
|
||||||
|
1. Открыть `Кошелёк`.
|
||||||
|
2. Перейти в `Поддержать проект Сияние`.
|
||||||
|
3. Проверить экран покупки:
|
||||||
|
- виден коэффициент;
|
||||||
|
- виден остаток лимита очереди 1;
|
||||||
|
- виден расчет в SOL;
|
||||||
|
- кнопка `Справка` открывает отдельный экран;
|
||||||
|
- покупка блокируется, если сумма больше остатка лимита.
|
||||||
|
4. Проверить экран просмотра:
|
||||||
|
- `12` ищется как билет очереди 1;
|
||||||
|
- `2-5` и `3 8` ищутся как билеты очередей 2 и 3;
|
||||||
|
- показываются статус, количество билетов до него и уже выплаченные значения.
|
||||||
|
5. Проверить генератор ключей:
|
||||||
|
- генерируется новая пара ключей;
|
||||||
|
- публичный и секретный ключи показываются;
|
||||||
|
- можно скопировать и скачать результат;
|
||||||
|
- дополнительный текст в поле необязателен.
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- Экран раздела поддержки открывается из `wallet-view`.
|
||||||
|
- Покупка билета выполняется по текущему курсу и с допуском 3%.
|
||||||
|
- По номеру билета показывается понятная сводка по очереди.
|
||||||
|
- Генерация ключей использует безопасный браузерный рандом и не требует сохранения секретного ключа.
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.285
|
client.version=1.2.286
|
||||||
server.version=1.2.265
|
server.version=1.2.266
|
||||||
|
|||||||
@ -50,7 +50,7 @@ import * as keyStorageView from './pages/key-storage-view.js';
|
|||||||
|
|
||||||
import * as profileView from './pages/profile-view.js';
|
import * as profileView from './pages/profile-view.js';
|
||||||
import * as profileEditView from './pages/profile-edit-view.js';
|
import * as profileEditView from './pages/profile-edit-view.js';
|
||||||
import * as walletView from './pages/wallet-view.js?v=202605300007';
|
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||||
import * as settingsView from './pages/settings-view.js';
|
import * as settingsView from './pages/settings-view.js';
|
||||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
transferAr,
|
transferAr,
|
||||||
} from '../services/arweave-wallet-service.js';
|
} from '../services/arweave-wallet-service.js';
|
||||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||||
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
import {
|
import {
|
||||||
calcLimitTopupPriceLamports,
|
calcLimitTopupPriceLamports,
|
||||||
getLimitStepBytes,
|
getLimitStepBytes,
|
||||||
@ -65,6 +66,312 @@ function sessionArgsOrThrow() {
|
|||||||
return { login, storagePwd };
|
return { login, storagePwd };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHINE_PAYMENTS_PROGRAM_ID = 'c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW';
|
||||||
|
const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE';
|
||||||
|
const SHINE_PAYMENTS_SEEDS = {
|
||||||
|
config: 'shine_payments_config',
|
||||||
|
coef: 'shine_payments_coef_limit',
|
||||||
|
queues: 'shine_payments_queues',
|
||||||
|
inflow: 'shine_payments_inflow_vault',
|
||||||
|
q1: 'shine_payments_q1_ticket',
|
||||||
|
q2: 'shine_payments_q2_ticket',
|
||||||
|
q3: 'shine_payments_q3_ticket',
|
||||||
|
};
|
||||||
|
const COEF_SCALE_PPM = 1_000_000n;
|
||||||
|
const LAMPORTS_PER_SOL = 1_000_000_000n;
|
||||||
|
const SUPPORT_TICKET_HELP_TEXT = [
|
||||||
|
'Билет привязан к очереди 1 и к адресу получателя, который вы укажете при покупке.',
|
||||||
|
'Оплата считается в USD, а списание идет в SOL по курсу USDT/SOL, который проверяется в момент транзакции.',
|
||||||
|
'Если курс уезжает слишком далеко, покупка отклоняется. Для интерфейса заложен допуск 3%.',
|
||||||
|
'Когда текущий лимит очереди заполнится, откроется новый лимит с более низким коэффициентом.',
|
||||||
|
'Номер билета нужно сохранить отдельно: именно по нему удобно отслеживать, когда он дойдет до выплаты.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function utf8Bytes(text) {
|
||||||
|
return new TextEncoder().encode(String(text || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatBytes(...parts) {
|
||||||
|
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
parts.forEach((part) => {
|
||||||
|
out.set(part, offset);
|
||||||
|
offset += part.length;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function u64ToBytes(value) {
|
||||||
|
const out = new Uint8Array(8);
|
||||||
|
let current = BigInt(value || 0);
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
out[i] = Number(current & 0xffn);
|
||||||
|
current >>= 8n;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readU64(data, offset) {
|
||||||
|
let value = 0n;
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
value |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readI64(data, offset) {
|
||||||
|
let value = readU64(data, offset);
|
||||||
|
if (value > 0x7fffffffffffffffn) {
|
||||||
|
value -= 0x10000000000000000n;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readI32(data, offset) {
|
||||||
|
let value = Number(readU64(data, offset) & 0xffffffffn);
|
||||||
|
if (value > 0x7fffffff) value -= 0x100000000;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimZeros(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/(\.\d*?[1-9])0+$/u, '$1')
|
||||||
|
.replace(/\.0+$/u, '')
|
||||||
|
.replace(/\.$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsdCentsText(cents) {
|
||||||
|
const value = Number(cents || 0n) / 100;
|
||||||
|
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPpmCoefText(coefPpm) {
|
||||||
|
const value = Number(coefPpm || 0n) / Number(COEF_SCALE_PPM);
|
||||||
|
return `${trimZeros(value.toFixed(6))}x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLamportsSolText(lamports, digits = 9) {
|
||||||
|
const value = Number(lamports || 0n) / Number(LAMPORTS_PER_SOL);
|
||||||
|
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: digits });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPythSolUsdText(pyth) {
|
||||||
|
const value = Number(pyth?.priceNum || 0n) / Number(pyth?.priceDen || 1n) / 100;
|
||||||
|
return trimZeros(value.toFixed(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQueuePrefix(queueId) {
|
||||||
|
if (queueId === 2) return 'Q2';
|
||||||
|
if (queueId === 3) return 'Q3';
|
||||||
|
return 'Q1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePaymentsCoef(data) {
|
||||||
|
let offset = 0;
|
||||||
|
const version = data[offset];
|
||||||
|
offset += 1;
|
||||||
|
const coefPpm = readU64(data, offset); offset += 8;
|
||||||
|
const limitUsdCents = readU64(data, offset); offset += 8;
|
||||||
|
const callRewardLamports = readU64(data, offset);
|
||||||
|
return { version, coefPpm, limitUsdCents, callRewardLamports };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePaymentsQueues(data) {
|
||||||
|
let offset = 0;
|
||||||
|
const version = data[offset];
|
||||||
|
offset += 1;
|
||||||
|
const q1TicketsTotal = readU64(data, offset); offset += 8;
|
||||||
|
const q1TicketsPaid = readU64(data, offset); offset += 8;
|
||||||
|
const q1SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||||
|
const q1SumPaidUsdCents = readU64(data, offset); offset += 8;
|
||||||
|
const q2TicketsTotal = readU64(data, offset); offset += 8;
|
||||||
|
const q2TicketsPaid = readU64(data, offset); offset += 8;
|
||||||
|
const q2SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||||
|
const q2SumPaidUsdCents = readU64(data, offset); offset += 8;
|
||||||
|
const q3TicketsTotal = readU64(data, offset); offset += 8;
|
||||||
|
const q3TicketsPaid = readU64(data, offset); offset += 8;
|
||||||
|
const q3SumTotalUsdCents = readU64(data, offset); offset += 8;
|
||||||
|
const q3SumPaidUsdCents = readU64(data, offset);
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
q1TicketsTotal,
|
||||||
|
q1TicketsPaid,
|
||||||
|
q1SumTotalUsdCents,
|
||||||
|
q1SumPaidUsdCents,
|
||||||
|
q2TicketsTotal,
|
||||||
|
q2TicketsPaid,
|
||||||
|
q2SumTotalUsdCents,
|
||||||
|
q2SumPaidUsdCents,
|
||||||
|
q3TicketsTotal,
|
||||||
|
q3TicketsPaid,
|
||||||
|
q3SumTotalUsdCents,
|
||||||
|
q3SumPaidUsdCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePaymentsTicket(data) {
|
||||||
|
let offset = 0;
|
||||||
|
const version = data[offset];
|
||||||
|
offset += 1;
|
||||||
|
const queueId = data[offset];
|
||||||
|
offset += 1;
|
||||||
|
const index = readU64(data, offset);
|
||||||
|
offset += 8;
|
||||||
|
const isPaid = Boolean(data[offset]);
|
||||||
|
offset += 1;
|
||||||
|
const solana = window.solanaWeb3;
|
||||||
|
const recipientWallet = new solana.PublicKey(data.slice(offset, offset + 32)).toBase58();
|
||||||
|
offset += 32;
|
||||||
|
const payoutUsdCents = readU64(data, offset);
|
||||||
|
offset += 8;
|
||||||
|
const debtBeforeUsdCents = readU64(data, offset);
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
queueId,
|
||||||
|
index,
|
||||||
|
isPaid,
|
||||||
|
recipientWallet,
|
||||||
|
payoutUsdCents,
|
||||||
|
debtBeforeUsdCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSupportTicketInput(rawValue) {
|
||||||
|
const clean = String(rawValue || '').trim();
|
||||||
|
if (!clean) throw new Error('Введите номер билета.');
|
||||||
|
if (/^\d+$/.test(clean)) {
|
||||||
|
return { queueId: 1, index: BigInt(clean) };
|
||||||
|
}
|
||||||
|
const match = clean.match(/^([123])(?:\s*[-_:\/#]+\s*|\s+)(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Формат: для Q1 просто номер, для Q2/Q3 - например 2-15 или 3 8.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
queueId: Number(match[1]),
|
||||||
|
index: BigInt(match[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveSupportRandomWallet(extraText) {
|
||||||
|
const solana = await loadSolanaWeb3();
|
||||||
|
if (!window.crypto?.getRandomValues || !window.crypto?.subtle) {
|
||||||
|
throw new Error('Этот браузер не поддерживает безопасную генерацию ключей.');
|
||||||
|
}
|
||||||
|
const entropy = new Uint8Array(32);
|
||||||
|
window.crypto.getRandomValues(entropy);
|
||||||
|
const payload = concatBytes(
|
||||||
|
entropy,
|
||||||
|
utf8Bytes(extraText || ''),
|
||||||
|
utf8Bytes(new Date().toISOString()),
|
||||||
|
utf8Bytes(String(Date.now())),
|
||||||
|
utf8Bytes(String(performance?.now?.() || 0)),
|
||||||
|
);
|
||||||
|
const seed = new Uint8Array(await window.crypto.subtle.digest('SHA-256', payload));
|
||||||
|
const keypair = solana.Keypair.fromSeed(seed);
|
||||||
|
return {
|
||||||
|
address: keypair.publicKey.toBase58(),
|
||||||
|
privateKey32Base58: solana.bs58.encode(seed),
|
||||||
|
keypair,
|
||||||
|
generatedAt: new Date().toLocaleString('ru-RU'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSupportPaymentsCore(endpoint) {
|
||||||
|
const solana = await loadSolanaWeb3();
|
||||||
|
const rpc = String(endpoint || '').trim();
|
||||||
|
const connection = new solana.Connection(rpc, 'confirmed');
|
||||||
|
const programId = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
|
||||||
|
const oracleAccount = new solana.PublicKey(SHINE_PAYMENTS_ORACLE_ACCOUNT);
|
||||||
|
const [configPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.config)], programId);
|
||||||
|
const [coefPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.coef)], programId);
|
||||||
|
const [queuesPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.queues)], programId);
|
||||||
|
const [inflowPda] = solana.PublicKey.findProgramAddressSync([utf8Bytes(SHINE_PAYMENTS_SEEDS.inflow)], programId);
|
||||||
|
const [oracleAi, configAi, coefAi, queuesAi] = await Promise.all([
|
||||||
|
connection.getAccountInfo(oracleAccount, 'confirmed'),
|
||||||
|
connection.getAccountInfo(configPda, 'confirmed'),
|
||||||
|
connection.getAccountInfo(coefPda, 'confirmed'),
|
||||||
|
connection.getAccountInfo(queuesPda, 'confirmed'),
|
||||||
|
]);
|
||||||
|
if (!oracleAi) throw new Error('Не найден аккаунт оракула SOL/USD.');
|
||||||
|
if (!configAi || !coefAi || !queuesAi) throw new Error('PDA программы оплаты ещё не инициализированы.');
|
||||||
|
|
||||||
|
const parsePrice = (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 { priceNum: num, priceDen: den, publishTime };
|
||||||
|
};
|
||||||
|
|
||||||
|
let configOffset = 0;
|
||||||
|
const configVersion = configAi.data[configOffset];
|
||||||
|
configOffset += 1;
|
||||||
|
const daoWallet = new solana.PublicKey(configAi.data.slice(configOffset, configOffset + 32)).toBase58();
|
||||||
|
configOffset += 32;
|
||||||
|
const inflowVault = new solana.PublicKey(configAi.data.slice(configOffset, configOffset + 32)).toBase58();
|
||||||
|
|
||||||
|
return {
|
||||||
|
connection,
|
||||||
|
programId,
|
||||||
|
oracleAccount,
|
||||||
|
configPda,
|
||||||
|
coefPda,
|
||||||
|
queuesPda,
|
||||||
|
inflowPda,
|
||||||
|
config: { version: configVersion, daoWallet, inflowVault },
|
||||||
|
coef: parsePaymentsCoef(coefAi.data),
|
||||||
|
queues: parsePaymentsQueues(queuesAi.data),
|
||||||
|
pyth: parsePrice(oracleAi.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueStateView(queues, queueId) {
|
||||||
|
if (queueId === 2) {
|
||||||
|
return {
|
||||||
|
ticketsTotal: queues.q2TicketsTotal,
|
||||||
|
ticketsPaid: queues.q2TicketsPaid,
|
||||||
|
sumTotalUsdCents: queues.q2SumTotalUsdCents,
|
||||||
|
sumPaidUsdCents: queues.q2SumPaidUsdCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (queueId === 3) {
|
||||||
|
return {
|
||||||
|
ticketsTotal: queues.q3TicketsTotal,
|
||||||
|
ticketsPaid: queues.q3TicketsPaid,
|
||||||
|
sumTotalUsdCents: queues.q3SumTotalUsdCents,
|
||||||
|
sumPaidUsdCents: queues.q3SumPaidUsdCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ticketsTotal: queues.q1TicketsTotal,
|
||||||
|
ticketsPaid: queues.q1TicketsPaid,
|
||||||
|
sumTotalUsdCents: queues.q1SumTotalUsdCents,
|
||||||
|
sumPaidUsdCents: queues.q1SumPaidUsdCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueSeedFor(queueId) {
|
||||||
|
if (queueId === 2) return SHINE_PAYMENTS_SEEDS.q2;
|
||||||
|
if (queueId === 3) return SHINE_PAYMENTS_SEEDS.q3;
|
||||||
|
return SHINE_PAYMENTS_SEEDS.q1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketPdaFor(programId, queueId, index) {
|
||||||
|
const solana = window.solanaWeb3;
|
||||||
|
return solana.PublicKey.findProgramAddressSync(
|
||||||
|
[utf8Bytes(queueSeedFor(queueId)), u64ToBytes(index)],
|
||||||
|
programId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
@ -99,6 +406,640 @@ export function render({ navigate }) {
|
|||||||
arweaveWalletCtx = null;
|
arweaveWalletCtx = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSupportHub() {
|
||||||
|
activeModeToken += 1;
|
||||||
|
clearArweaveSecretsInMemory();
|
||||||
|
content.innerHTML = '';
|
||||||
|
|
||||||
|
const backBtn = createModeBackButton(renderWalletChoice);
|
||||||
|
|
||||||
|
const intro = document.createElement('div');
|
||||||
|
intro.className = 'card stack';
|
||||||
|
intro.innerHTML = `
|
||||||
|
<h2 style="margin:0;">Поддержать проект Сияние</h2>
|
||||||
|
<p class="meta-muted" style="margin:0; line-height:1.5;">
|
||||||
|
Здесь можно купить билет, посмотреть очередь и увидеть, как устроена покупка.
|
||||||
|
Оплата идет в SOL, а сумма считается по курсу USD/USDT на момент транзакции.
|
||||||
|
Номер билета и секретный ключ кошелька нужно сохранить отдельно.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'stack';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="primary-btn" type="button" id="support-buy" style="width:100%;">Купить билет</button>
|
||||||
|
<button class="primary-btn" type="button" id="support-queue" style="width:100%;">Посмотреть очередь</button>
|
||||||
|
<button class="primary-btn" type="button" id="support-keygen" style="width:100%;">Сгенерировать новую пару ключей</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
actions.querySelector('#support-buy')?.addEventListener('click', () => {
|
||||||
|
void renderSupportBuy();
|
||||||
|
});
|
||||||
|
actions.querySelector('#support-queue')?.addEventListener('click', () => {
|
||||||
|
void renderSupportQueue();
|
||||||
|
});
|
||||||
|
actions.querySelector('#support-keygen')?.addEventListener('click', () => {
|
||||||
|
void renderSupportKeygen();
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(backBtn, intro, actions);
|
||||||
|
setStatus('Выберите действие в разделе поддержки.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSupportHelp(backTarget = renderSupportBuy) {
|
||||||
|
const modeToken = ++activeModeToken;
|
||||||
|
clearArweaveSecretsInMemory();
|
||||||
|
content.innerHTML = '';
|
||||||
|
|
||||||
|
const backBtn = createModeBackButton(backTarget);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card stack';
|
||||||
|
card.innerHTML = `
|
||||||
|
<h2 style="margin:0;">Справка по покупке билета</h2>
|
||||||
|
<p class="meta-muted" style="margin:0; white-space:pre-wrap; line-height:1.55;">${SUPPORT_TICKET_HELP_TEXT}</p>
|
||||||
|
<p class="meta-muted" style="margin:0;">
|
||||||
|
После покупки билет остается в очереди 1. Позже можно открыть экран просмотра и проверить, сколько билетов и сумм уже прошло перед ним.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
content.append(backBtn, card);
|
||||||
|
if (modeToken === activeModeToken) setStatus('Открыта подробная справка.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSupportKeygen() {
|
||||||
|
const modeToken = ++activeModeToken;
|
||||||
|
clearArweaveSecretsInMemory();
|
||||||
|
content.innerHTML = '';
|
||||||
|
|
||||||
|
const backBtn = createModeBackButton(renderSupportHub);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card stack';
|
||||||
|
card.innerHTML = `
|
||||||
|
<h2 style="margin:0;">Сгенерировать новую пару ключей</h2>
|
||||||
|
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||||
|
Генерация использует безопасный рандом браузера, а ваш дополнительный текст и текущее время добавляются как примесь.
|
||||||
|
Вводить текст необязательно: даже без него ключи остаются случайными.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const saltLabel = document.createElement('label');
|
||||||
|
saltLabel.className = 'meta-muted';
|
||||||
|
saltLabel.setAttribute('for', 'support-key-salt');
|
||||||
|
saltLabel.textContent = 'Дополнительная соль (необязательно)';
|
||||||
|
|
||||||
|
const saltInput = document.createElement('textarea');
|
||||||
|
saltInput.id = 'support-key-salt';
|
||||||
|
saltInput.rows = 3;
|
||||||
|
saltInput.placeholder = 'Можно оставить пустым или добавить любой текст как дополнительную примесь';
|
||||||
|
saltInput.spellcheck = false;
|
||||||
|
|
||||||
|
const timeLabel = document.createElement('p');
|
||||||
|
timeLabel.className = 'meta-muted';
|
||||||
|
timeLabel.textContent = 'Время генерации появится после нажатия кнопки.';
|
||||||
|
|
||||||
|
const generatedPublicLabel = document.createElement('label');
|
||||||
|
generatedPublicLabel.className = 'meta-muted';
|
||||||
|
generatedPublicLabel.setAttribute('for', 'support-generated-public');
|
||||||
|
generatedPublicLabel.textContent = 'Публичный ключ';
|
||||||
|
|
||||||
|
const generatedPublicInput = document.createElement('input');
|
||||||
|
generatedPublicInput.id = 'support-generated-public';
|
||||||
|
generatedPublicInput.type = 'text';
|
||||||
|
generatedPublicInput.readOnly = true;
|
||||||
|
generatedPublicInput.placeholder = 'Появится после генерации';
|
||||||
|
|
||||||
|
const generatedSecretLabel = document.createElement('label');
|
||||||
|
generatedSecretLabel.className = 'meta-muted';
|
||||||
|
generatedSecretLabel.setAttribute('for', 'support-generated-secret');
|
||||||
|
generatedSecretLabel.textContent = 'Секретный ключ (Base58, показывается один раз)';
|
||||||
|
|
||||||
|
const generatedSecretInput = document.createElement('textarea');
|
||||||
|
generatedSecretInput.id = 'support-generated-secret';
|
||||||
|
generatedSecretInput.rows = 4;
|
||||||
|
generatedSecretInput.readOnly = true;
|
||||||
|
generatedSecretInput.placeholder = 'Появится после генерации';
|
||||||
|
generatedSecretInput.spellcheck = false;
|
||||||
|
|
||||||
|
const generatedAddressNote = document.createElement('p');
|
||||||
|
generatedAddressNote.className = 'meta-muted';
|
||||||
|
generatedAddressNote.style.margin = '0';
|
||||||
|
generatedAddressNote.textContent = 'Secret key не сохраняется на сервере и не пишется в localStorage.';
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'stack';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="primary-btn" type="button" id="support-generate" style="width:100%;">Сгенерировать пару ключей</button>
|
||||||
|
<div class="row">
|
||||||
|
<button class="text-btn" type="button" id="support-copy-public" style="width:100%;">Копировать public key</button>
|
||||||
|
<button class="text-btn" type="button" id="support-copy-secret" style="width:100%;">Копировать secret key</button>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-btn" type="button" id="support-download" style="width:100%;">Скачать ключи</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const generateBtn = actions.querySelector('#support-generate');
|
||||||
|
const copyPublicBtn = actions.querySelector('#support-copy-public');
|
||||||
|
const copySecretBtn = actions.querySelector('#support-copy-secret');
|
||||||
|
const downloadBtn = actions.querySelector('#support-download');
|
||||||
|
let generatedPair = null;
|
||||||
|
|
||||||
|
const clearGenerated = () => {
|
||||||
|
generatedPair = null;
|
||||||
|
generatedPublicInput.value = '';
|
||||||
|
generatedSecretInput.value = '';
|
||||||
|
timeLabel.textContent = 'Время генерации появится после нажатия кнопки.';
|
||||||
|
};
|
||||||
|
|
||||||
|
clearGenerated();
|
||||||
|
|
||||||
|
generateBtn?.addEventListener('click', async () => {
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
generatedPair = await deriveSupportRandomWallet(saltInput.value);
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
generatedPublicInput.value = generatedPair.address;
|
||||||
|
generatedSecretInput.value = generatedPair.privateKey32Base58;
|
||||||
|
timeLabel.textContent = `Сгенерировано: ${generatedPair.generatedAt}. Использован безопасный рандом браузера, текст и время добавлены как примесь.`;
|
||||||
|
setStatus('Новая пара ключей сгенерирована. Секретный ключ показан на экране один раз.');
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Не удалось сгенерировать пару ключей: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
copyPublicBtn?.addEventListener('click', async () => {
|
||||||
|
const value = String(generatedPublicInput.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
setStatus('Сначала сгенерируйте пару ключей.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setStatus('Public key скопирован.');
|
||||||
|
} catch {
|
||||||
|
setStatus('Не удалось скопировать public key.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
copySecretBtn?.addEventListener('click', async () => {
|
||||||
|
const value = String(generatedSecretInput.value || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
setStatus('Сначала сгенерируйте пару ключей.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setStatus('Secret key скопирован.');
|
||||||
|
} catch {
|
||||||
|
setStatus('Не удалось скопировать secret key.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadBtn?.addEventListener('click', () => {
|
||||||
|
const publicKey = String(generatedPublicInput.value || '').trim();
|
||||||
|
const secretKey = String(generatedSecretInput.value || '').trim();
|
||||||
|
if (!publicKey || !secretKey) {
|
||||||
|
setStatus('Сначала сгенерируйте пару ключей.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = [
|
||||||
|
`Публичный ключ: ${publicKey}`,
|
||||||
|
`Секретный ключ: ${secretKey}`,
|
||||||
|
`Время: ${timeLabel.textContent || ''}`,
|
||||||
|
].join('\n');
|
||||||
|
const blob = new Blob([payload], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `shine-keypair-${Date.now()}.txt`;
|
||||||
|
link.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
setStatus('Файл с ключами скачан.');
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(
|
||||||
|
backBtn,
|
||||||
|
card,
|
||||||
|
saltLabel,
|
||||||
|
saltInput,
|
||||||
|
timeLabel,
|
||||||
|
generatedPublicLabel,
|
||||||
|
generatedPublicInput,
|
||||||
|
generatedSecretLabel,
|
||||||
|
generatedSecretInput,
|
||||||
|
generatedAddressNote,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
setStatus('Генератор ключей готов. Дополнительная соль необязательна.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSupportQueue() {
|
||||||
|
const modeToken = ++activeModeToken;
|
||||||
|
clearArweaveSecretsInMemory();
|
||||||
|
content.innerHTML = '';
|
||||||
|
|
||||||
|
const backBtn = createModeBackButton(renderSupportHub);
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card stack';
|
||||||
|
card.innerHTML = `
|
||||||
|
<h2 style="margin:0;">Посмотреть очередь билета</h2>
|
||||||
|
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||||
|
Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате <code>2-15</code> или <code>3 8</code>.
|
||||||
|
Экран покажет, сколько билетов перед ним уже стоит, сколько уже выплачено и кому принадлежит билет.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const inputLabel = document.createElement('label');
|
||||||
|
inputLabel.className = 'meta-muted';
|
||||||
|
inputLabel.setAttribute('for', 'support-ticket-query');
|
||||||
|
inputLabel.textContent = 'Номер билета';
|
||||||
|
|
||||||
|
const queryInput = document.createElement('input');
|
||||||
|
queryInput.id = 'support-ticket-query';
|
||||||
|
queryInput.type = 'text';
|
||||||
|
queryInput.placeholder = 'Например: 12, 2-5 или 3 8';
|
||||||
|
queryInput.autocomplete = 'off';
|
||||||
|
queryInput.spellcheck = false;
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'row';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<button class="primary-btn" type="button" id="support-ticket-load" style="width:100%;">Показать билет</button>
|
||||||
|
<button class="ghost-btn" type="button" id="support-ticket-reset" style="width:100%;">Сбросить</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = document.createElement('div');
|
||||||
|
result.className = 'card stack';
|
||||||
|
result.innerHTML = `
|
||||||
|
<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let lastCoreState = null;
|
||||||
|
|
||||||
|
const renderTicketInfo = async () => {
|
||||||
|
const raw = String(queryInput.value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = parseSupportTicketInput(raw);
|
||||||
|
const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||||
|
lastCoreState = core;
|
||||||
|
const solana = await loadSolanaWeb3();
|
||||||
|
const ticketPdaSeed = queueSeedFor(parsed.queueId);
|
||||||
|
const ticketIndexBytes = u64ToBytes(parsed.index);
|
||||||
|
const [ticketPda] = solana.PublicKey.findProgramAddressSync(
|
||||||
|
[utf8Bytes(ticketPdaSeed), ticketIndexBytes],
|
||||||
|
core.programId,
|
||||||
|
);
|
||||||
|
const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed');
|
||||||
|
if (!ticketInfo) {
|
||||||
|
result.innerHTML = `
|
||||||
|
<p class="meta-muted" style="margin:0;">Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.</p>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticket = parsePaymentsTicket(ticketInfo.data);
|
||||||
|
const queueState = queueStateView(core.queues, parsed.queueId);
|
||||||
|
const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n;
|
||||||
|
const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore;
|
||||||
|
const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n;
|
||||||
|
result.innerHTML = `
|
||||||
|
<div><b>Билет:</b> ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}</div>
|
||||||
|
<div><b>Статус:</b> ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}</div>
|
||||||
|
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
|
||||||
|
<div><b>До него в очереди:</b> ${ticketsBefore.toString()} билетов</div>
|
||||||
|
<div><b>Из них уже выплачено:</b> ${paidBefore.toString()} билетов</div>
|
||||||
|
<div><b>Ещё осталось до него:</b> ${remainingBefore.toString()} билетов</div>
|
||||||
|
<div><b>Уже выплачено по сумме в очереди:</b> ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD</div>
|
||||||
|
<div><b>Сумма этого билета:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
|
||||||
|
<div><b>Накоплено до этого билета:</b> ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него</div>
|
||||||
|
`;
|
||||||
|
setStatus(`Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`);
|
||||||
|
} catch (error) {
|
||||||
|
result.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить билет')}</p>`;
|
||||||
|
setStatus(`Не удалось посмотреть билет: ${error?.message || 'unknown'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
actions.querySelector('#support-ticket-load')?.addEventListener('click', () => {
|
||||||
|
void renderTicketInfo();
|
||||||
|
});
|
||||||
|
actions.querySelector('#support-ticket-reset')?.addEventListener('click', () => {
|
||||||
|
queryInput.value = '';
|
||||||
|
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
|
||||||
|
setStatus('Поле билета очищено.');
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(backBtn, card, inputLabel, queryInput, actions, result);
|
||||||
|
setStatus('Просмотр билета готов. Введите номер в нужном формате.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSupportBuy() {
|
||||||
|
const modeToken = ++activeModeToken;
|
||||||
|
clearArweaveSecretsInMemory();
|
||||||
|
content.innerHTML = '';
|
||||||
|
|
||||||
|
const backBtn = createModeBackButton(renderSupportHub);
|
||||||
|
const helpCard = document.createElement('div');
|
||||||
|
helpCard.className = 'card stack';
|
||||||
|
helpCard.innerHTML = `
|
||||||
|
<h2 style="margin:0;">Купить билет</h2>
|
||||||
|
<p class="meta-muted" style="margin:0; line-height:1.55;">
|
||||||
|
Вы покупаете только билет очереди 1. Сумма задается в USD, а оплата уходит в SOL по курсу USDT/SOL на момент транзакции.
|
||||||
|
Покупка подписывается вашим client key. Интерфейс проверяет допуск по курсу до 3 процентов и не дает выходить за текущий лимит по коэффициенту.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const stateCard = document.createElement('div');
|
||||||
|
stateCard.className = 'card stack';
|
||||||
|
stateCard.innerHTML = `
|
||||||
|
<p class="meta-muted" style="margin:0;">Текущее состояние покупки загружается...</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const amountLabel = document.createElement('label');
|
||||||
|
amountLabel.className = 'meta-muted';
|
||||||
|
amountLabel.setAttribute('for', 'support-buy-amount');
|
||||||
|
amountLabel.textContent = 'Сумма билета в долларах';
|
||||||
|
|
||||||
|
const amountInput = document.createElement('input');
|
||||||
|
amountInput.id = 'support-buy-amount';
|
||||||
|
amountInput.type = 'text';
|
||||||
|
amountInput.value = '20';
|
||||||
|
amountInput.inputMode = 'decimal';
|
||||||
|
amountInput.autocomplete = 'off';
|
||||||
|
|
||||||
|
const recipientWrap = document.createElement('div');
|
||||||
|
recipientWrap.className = 'stack';
|
||||||
|
const recipientInput = document.createElement('input');
|
||||||
|
recipientInput.id = 'support-buy-recipient';
|
||||||
|
recipientInput.type = 'text';
|
||||||
|
recipientInput.placeholder = 'Можно оставить пустым';
|
||||||
|
recipientInput.autocomplete = 'off';
|
||||||
|
recipientInput.spellcheck = false;
|
||||||
|
const recipientLabel = document.createElement('label');
|
||||||
|
recipientLabel.className = 'meta-muted';
|
||||||
|
recipientLabel.setAttribute('for', 'support-buy-recipient');
|
||||||
|
recipientLabel.textContent = 'Адрес кошелька для билета и выплаты';
|
||||||
|
recipientWrap.append(recipientLabel, recipientInput);
|
||||||
|
|
||||||
|
const sameWalletRow = document.createElement('label');
|
||||||
|
sameWalletRow.className = 'row';
|
||||||
|
sameWalletRow.style.gap = '10px';
|
||||||
|
sameWalletRow.style.alignItems = 'center';
|
||||||
|
sameWalletRow.innerHTML = `
|
||||||
|
<input type="checkbox" id="support-buy-same-wallet" checked />
|
||||||
|
<span class="meta-muted">На тот же кошелёк, с которого покупаем</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const quoteCard = document.createElement('div');
|
||||||
|
quoteCard.className = 'card stack';
|
||||||
|
quoteCard.innerHTML = `<p class="meta-muted" style="margin:0;">Расчет появится после загрузки курса.</p>`;
|
||||||
|
|
||||||
|
const purchaseResultCard = document.createElement('div');
|
||||||
|
purchaseResultCard.className = 'card stack';
|
||||||
|
purchaseResultCard.innerHTML = `
|
||||||
|
<p class="meta-muted" style="margin:0;">После покупки здесь появится номер билета и краткий итог.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'stack';
|
||||||
|
actions.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<button class="primary-btn" type="button" id="support-buy-submit" style="width:100%;">Купить</button>
|
||||||
|
<button class="ghost-btn" type="button" id="support-buy-refresh" style="width:100%;">Обновить условия</button>
|
||||||
|
</div>
|
||||||
|
<button class="text-btn" type="button" id="support-buy-help" style="width:100%;">Справка</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const buyBtn = actions.querySelector('#support-buy-submit');
|
||||||
|
const refreshBtn = actions.querySelector('#support-buy-refresh');
|
||||||
|
const helpBtn = actions.querySelector('#support-buy-help');
|
||||||
|
const sameWalletCheckbox = sameWalletRow.querySelector('#support-buy-same-wallet');
|
||||||
|
|
||||||
|
let walletCtx = null;
|
||||||
|
let walletAddress = '';
|
||||||
|
let currentCore = null;
|
||||||
|
|
||||||
|
const setRecipientFromWallet = () => {
|
||||||
|
if (sameWalletCheckbox?.checked && walletAddress) {
|
||||||
|
recipientInput.value = walletAddress;
|
||||||
|
recipientInput.disabled = true;
|
||||||
|
} else {
|
||||||
|
recipientInput.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuote = () => {
|
||||||
|
if (!currentCore) return;
|
||||||
|
const queue = queueStateView(currentCore.queues, 1);
|
||||||
|
const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents
|
||||||
|
? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents
|
||||||
|
: 0n;
|
||||||
|
const currentCoef = formatPpmCoefText(currentCore.coef.coefPpm);
|
||||||
|
const usdRaw = String(amountInput.value || '').trim().replace(',', '.');
|
||||||
|
let amountUsdCents = 0n;
|
||||||
|
let amountSol = '—';
|
||||||
|
let maxPaySol = '—';
|
||||||
|
let canBuy = true;
|
||||||
|
try {
|
||||||
|
const asNumber = Number(usdRaw);
|
||||||
|
if (!Number.isFinite(asNumber) || asNumber <= 0) throw new Error('bad amount');
|
||||||
|
amountUsdCents = BigInt(Math.round(asNumber * 100));
|
||||||
|
const payLamports = (amountUsdCents * LAMPORTS_PER_SOL * currentCore.pyth.priceDen + currentCore.pyth.priceNum - 1n) / currentCore.pyth.priceNum;
|
||||||
|
const maxPayLamports = (payLamports * 103n + 99n) / 100n;
|
||||||
|
amountSol = formatLamportsSolText(payLamports, 9);
|
||||||
|
maxPaySol = formatLamportsSolText(maxPayLamports, 9);
|
||||||
|
if (amountUsdCents > remainingUsdCents) canBuy = false;
|
||||||
|
} catch {
|
||||||
|
canBuy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteCard.innerHTML = `
|
||||||
|
<div><b>Коэффициент сейчас:</b> ${currentCoef}</div>
|
||||||
|
<div><b>В очереди перед вами уже куплено:</b> ${formatUsdCentsText(queue.sumTotalUsdCents)} USD</div>
|
||||||
|
<div><b>Осталось до лимита текущего коэффициента:</b> ${formatUsdCentsText(remainingUsdCents)} USD</div>
|
||||||
|
<div><b>Следующий билет:</b> №${(queue.ticketsTotal + 1n).toString()}</div>
|
||||||
|
<div><b>Примерная цена:</b> ${amountSol} SOL</div>
|
||||||
|
<div><b>Максимум при допуске 3%:</b> ${maxPaySol} SOL</div>
|
||||||
|
<div class="${canBuy ? 'ok' : 'warn'}">${canBuy ? 'Покупка укладывается в текущий лимит.' : 'Сумма сейчас не укладывается в текущий лимит или введена некорректно.'}</div>
|
||||||
|
<div class="meta-muted" style="margin:0; white-space:pre-wrap; line-height:1.5;">
|
||||||
|
Если текущий лимит заполнится, откроется новый лимит с более низким коэффициентом.
|
||||||
|
Покупка идет в SOL, но считается по курсу USD/USDT на момент транзакции.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const hasRecipient = sameWalletCheckbox?.checked
|
||||||
|
? Boolean(walletAddress)
|
||||||
|
: Boolean(String(recipientInput.value || '').trim());
|
||||||
|
buyBtn.disabled = !canBuy || !hasRecipient;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshCore = async () => {
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
const queue = queueStateView(currentCore.queues, 1);
|
||||||
|
const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents
|
||||||
|
? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents
|
||||||
|
: 0n;
|
||||||
|
stateCard.innerHTML = `
|
||||||
|
<div><b>Коэффициент:</b> ${formatPpmCoefText(currentCore.coef.coefPpm)}</div>
|
||||||
|
<div><b>Лимит текущего коэффициента:</b> ${formatUsdCentsText(currentCore.coef.limitUsdCents)} USD</div>
|
||||||
|
<div><b>Уже куплено в очереди 1:</b> ${formatUsdCentsText(queue.sumTotalUsdCents)} USD</div>
|
||||||
|
<div><b>Осталось купить по текущему коэффициенту:</b> ${formatUsdCentsText(remainingUsdCents)} USD</div>
|
||||||
|
<div><b>Сколько билетов уже в очереди:</b> ${queue.ticketsTotal.toString()}</div>
|
||||||
|
<div><b>Следующий билет:</b> №${(queue.ticketsTotal + 1n).toString()}</div>
|
||||||
|
<div><b>Курс:</b> 1 SOL = ${formatPythSolUsdText(currentCore.pyth)} USD</div>
|
||||||
|
<div class="meta-muted" style="margin:0; line-height:1.5;">
|
||||||
|
После заполнения лимита будет новый лимит, но уже с более низким коэффициентом.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
setRecipientFromWallet();
|
||||||
|
updateQuote();
|
||||||
|
setStatus('Условия покупки обновлены.');
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
stateCard.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить состояние')}</p>`;
|
||||||
|
quoteCard.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось рассчитать цену')}</p>`;
|
||||||
|
setStatus(`Не удалось загрузить условия покупки: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
amountInput.addEventListener('input', updateQuote);
|
||||||
|
recipientInput.addEventListener('input', updateQuote);
|
||||||
|
sameWalletCheckbox?.addEventListener('change', () => {
|
||||||
|
setRecipientFromWallet();
|
||||||
|
updateQuote();
|
||||||
|
});
|
||||||
|
|
||||||
|
helpBtn?.addEventListener('click', () => {
|
||||||
|
void renderSupportHelp(renderSupportBuy);
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn?.addEventListener('click', () => {
|
||||||
|
void refreshCore();
|
||||||
|
});
|
||||||
|
|
||||||
|
buyBtn?.addEventListener('click', async () => {
|
||||||
|
buyBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (!currentCore) {
|
||||||
|
currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||||||
|
}
|
||||||
|
if (!walletCtx) {
|
||||||
|
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||||
|
walletAddress = walletCtx.address;
|
||||||
|
}
|
||||||
|
setRecipientFromWallet();
|
||||||
|
const queue = queueStateView(currentCore.queues, 1);
|
||||||
|
const remainingUsdCents = currentCore.coef.limitUsdCents > queue.sumTotalUsdCents
|
||||||
|
? currentCore.coef.limitUsdCents - queue.sumTotalUsdCents
|
||||||
|
: 0n;
|
||||||
|
const amountUsd = String(amountInput.value || '').trim().replace(',', '.');
|
||||||
|
const amountUsdNumber = Number(amountUsd);
|
||||||
|
if (!Number.isFinite(amountUsdNumber) || amountUsdNumber <= 0) {
|
||||||
|
throw new Error('Введите корректную сумму в долларах.');
|
||||||
|
}
|
||||||
|
const amountUsdCents = BigInt(Math.round(amountUsdNumber * 100));
|
||||||
|
if (amountUsdCents > remainingUsdCents) {
|
||||||
|
throw new Error('Сумма превышает остаток текущего лимита. Уменьшите сумму или дождитесь нового лимита.');
|
||||||
|
}
|
||||||
|
const recipientWallet = String(recipientInput.value || '').trim() || walletAddress;
|
||||||
|
if (!recipientWallet) throw new Error('Не указан кошелёк получателя.');
|
||||||
|
const solana = await loadSolanaWeb3();
|
||||||
|
const recipientPubkey = new solana.PublicKey(recipientWallet);
|
||||||
|
const payLamports = (amountUsdCents * LAMPORTS_PER_SOL * currentCore.pyth.priceDen + currentCore.pyth.priceNum - 1n) / currentCore.pyth.priceNum;
|
||||||
|
const maxPayLamports = (payLamports * 103n + 99n) / 100n;
|
||||||
|
const nextIndex = queue.ticketsTotal + 1n;
|
||||||
|
const [ticketPda] = solana.PublicKey.findProgramAddressSync(
|
||||||
|
[utf8Bytes(SHINE_PAYMENTS_SEEDS.q1), u64ToBytes(nextIndex)],
|
||||||
|
currentCore.programId,
|
||||||
|
);
|
||||||
|
const data = concatBytes(
|
||||||
|
new Uint8Array([5]),
|
||||||
|
u64ToBytes(amountUsdCents),
|
||||||
|
u64ToBytes(maxPayLamports),
|
||||||
|
recipientPubkey.toBytes(),
|
||||||
|
);
|
||||||
|
const ix = new solana.TransactionInstruction({
|
||||||
|
programId: currentCore.programId,
|
||||||
|
keys: [
|
||||||
|
{ pubkey: walletCtx.keypair.publicKey, isSigner: true, isWritable: true },
|
||||||
|
{ pubkey: currentCore.configPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: currentCore.coefPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: currentCore.queuesPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: ticketPda, isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: new solana.PublicKey(currentCore.config.daoWallet), isSigner: false, isWritable: true },
|
||||||
|
{ pubkey: currentCore.oracleAccount, isSigner: false, isWritable: false },
|
||||||
|
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const connection = new solana.Connection(String(state.entrySettings.solanaServer || '').trim(), 'confirmed');
|
||||||
|
const tx = new solana.Transaction().add(ix);
|
||||||
|
tx.feePayer = walletCtx.keypair.publicKey;
|
||||||
|
const bh = await connection.getLatestBlockhash('confirmed');
|
||||||
|
tx.recentBlockhash = bh.blockhash;
|
||||||
|
tx.partialSign(walletCtx.keypair);
|
||||||
|
const signature = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false });
|
||||||
|
await connection.confirmTransaction(
|
||||||
|
{ signature, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight },
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
const ticketInfo = await connection.getAccountInfo(ticketPda, 'confirmed');
|
||||||
|
const ticket = ticketInfo?.data
|
||||||
|
? parsePaymentsTicket(ticketInfo.data)
|
||||||
|
: { index: nextIndex, queueId: 1, recipientWallet, payoutUsdCents: amountUsdCents };
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()} куплен. Сохраните номер билета и secret key используемого кошелька.`);
|
||||||
|
purchaseResultCard.innerHTML = `
|
||||||
|
<div><b>Покупка завершена:</b> билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()}</div>
|
||||||
|
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
|
||||||
|
<div><b>Сумма билета:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
|
||||||
|
<div><b>До него было в очереди:</b> ${ticket.index > 0n ? (ticket.index - 1n).toString() : '0'} билетов</div>
|
||||||
|
<div><b>Транзакция:</b> <span style="word-break:break-all;">${signature}</span></div>
|
||||||
|
`;
|
||||||
|
await refreshCore();
|
||||||
|
} catch (error) {
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setStatus(`Не удалось купить билет: ${error?.message || 'unknown'}`);
|
||||||
|
} finally {
|
||||||
|
buyBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
content.append(
|
||||||
|
backBtn,
|
||||||
|
helpCard,
|
||||||
|
stateCard,
|
||||||
|
amountLabel,
|
||||||
|
amountInput,
|
||||||
|
recipientWrap,
|
||||||
|
sameWalletRow,
|
||||||
|
quoteCard,
|
||||||
|
purchaseResultCard,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!walletCtx) {
|
||||||
|
try {
|
||||||
|
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
|
||||||
|
walletAddress = walletCtx.address;
|
||||||
|
if (modeToken !== activeModeToken) return;
|
||||||
|
setRecipientFromWallet();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Не удалось загрузить client.key: ${error?.message || 'unknown'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshCore();
|
||||||
|
}
|
||||||
|
|
||||||
function renderWalletChoice() {
|
function renderWalletChoice() {
|
||||||
activeModeToken += 1;
|
activeModeToken += 1;
|
||||||
clearArweaveSecretsInMemory();
|
clearArweaveSecretsInMemory();
|
||||||
@ -135,7 +1076,15 @@ export function render({ navigate }) {
|
|||||||
void renderShineBlockchainWallet();
|
void renderShineBlockchainWallet();
|
||||||
});
|
});
|
||||||
|
|
||||||
card.append(solanaBtn, arweaveBtn, shineBchBtn);
|
const supportBtn = document.createElement('button');
|
||||||
|
supportBtn.className = 'primary-btn';
|
||||||
|
supportBtn.style.width = '100%';
|
||||||
|
supportBtn.textContent = 'Поддержать проект Сияние';
|
||||||
|
supportBtn.addEventListener('click', () => {
|
||||||
|
void renderSupportHub();
|
||||||
|
});
|
||||||
|
|
||||||
|
card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn);
|
||||||
content.append(card);
|
content.append(card);
|
||||||
setStatus('Выберите тип кошелька.');
|
setStatus('Выберите тип кошелька.');
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user