SHiNE-server/shine-UI/js/pages/wallet-view.js

1880 lines
80 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import {
createRandomSolanaWallet,
createSolanaWalletFromPrivateBase58,
formatSol,
getBalanceSol,
getTopupSiteUrl,
getWalletFromStoredClientKey,
transferSol,
} from '../services/solana-wallet-service.js';
import {
formatAr,
getArweaveBalance,
getArweaveTopupSiteUrl,
getArweaveWalletFromStoredClientKey,
transferAr,
} from '../services/arweave-wallet-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
import {
calcLimitTopupPriceLamports,
getLimitStepBytes,
getShineBlockchainUsage,
getShineUsersEconomyConfig,
updateShineUserPdaOnSolana,
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
function nowRu() {
return new Date().toLocaleString('ru-RU');
}
function formatKbFromBytes(rawBytes) {
const bytes = typeof rawBytes === 'bigint'
? Number(rawBytes)
: Number(rawBytes || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return '0 KB';
const kb = bytes / 1024;
return `${kb.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KB`;
}
function lamportsToSolText(lamportsBigInt) {
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
}
function createModeBackButton(renderWalletChoice) {
const backBtn = document.createElement('button');
backBtn.className = 'text-btn';
backBtn.textContent = '← К выбору кошелька';
backBtn.addEventListener('click', () => {
renderWalletChoice();
});
return backBtn;
}
function sessionArgsOrThrow() {
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) {
throw new Error('Нет активной сессии. Выполните вход заново.');
}
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 }) {
const screen = document.createElement('section');
screen.className = 'stack';
const status = document.createElement('p');
status.className = 'meta-muted';
const setStatus = (text) => {
status.textContent = String(text || '');
};
const content = document.createElement('div');
content.className = 'stack';
screen.append(
renderHeader({
title: 'Кошелёк',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
content,
status,
);
let activeModeToken = 0;
let arweaveWalletCtx = null;
function clearArweaveSecretsInMemory() {
if (!arweaveWalletCtx?.jwk) return;
Object.keys(arweaveWalletCtx.jwk).forEach((key) => {
arweaveWalletCtx.jwk[key] = '';
});
arweaveWalletCtx.jwk = 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() {
activeModeToken += 1;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<h2 style="margin:0 0 6px;">Кошелёк</h2>
<p class="meta-muted">Выберите режим кошелька.</p>
`;
const solanaBtn = document.createElement('button');
solanaBtn.className = 'primary-btn';
solanaBtn.style.width = '100%';
solanaBtn.textContent = 'Solana кошелёк';
solanaBtn.addEventListener('click', () => {
void renderSolanaWallet();
});
const arweaveBtn = document.createElement('button');
arweaveBtn.className = 'primary-btn';
arweaveBtn.style.width = '100%';
arweaveBtn.textContent = 'Arweave кошелёк';
arweaveBtn.addEventListener('click', () => {
void renderArweaveWallet();
});
const shineBchBtn = document.createElement('button');
shineBchBtn.className = 'primary-btn';
shineBchBtn.style.width = '100%';
shineBchBtn.textContent = 'Блокчейн Сияния';
shineBchBtn.addEventListener('click', () => {
void renderShineBlockchainWallet();
});
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);
setStatus('Выберите тип кошелька.');
}
async function renderShineBlockchainWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const backBtn = createModeBackButton(renderWalletChoice);
const card = document.createElement('div');
card.className = 'card stack';
const limitLabel = document.createElement('p');
limitLabel.className = 'meta-muted';
limitLabel.textContent = 'Лимит блокчейна';
const limitValue = document.createElement('h2');
limitValue.style.fontSize = '26px';
limitValue.textContent = '— KB';
const usedLabel = document.createElement('p');
usedLabel.className = 'meta-muted';
usedLabel.textContent = 'Израсходовано (фактически на сервере)';
const usedValue = document.createElement('h2');
usedValue.style.fontSize = '26px';
usedValue.textContent = '— KB';
const leftLabel = document.createElement('p');
leftLabel.className = 'meta-muted';
leftLabel.textContent = 'Осталось';
const leftValue = document.createElement('h2');
leftValue.style.fontSize = '30px';
leftValue.textContent = '— KB';
const pdaLabel = document.createElement('p');
pdaLabel.className = 'meta-muted';
pdaLabel.style.wordBreak = 'break-all';
pdaLabel.textContent = 'PDA: —';
const endpointLabel = document.createElement('p');
endpointLabel.className = 'meta-muted';
endpointLabel.textContent = `RPC: ${state.entrySettings.solanaServer}`;
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
const serverTitle = document.createElement('h3');
serverTitle.style.margin = '14px 0 0';
serverTitle.textContent = 'Фактическое состояние на сервере';
const serverSizeLabel = document.createElement('p');
serverSizeLabel.className = 'meta-muted';
serverSizeLabel.textContent = 'Размер цепочки: —';
const serverLastLabel = document.createElement('p');
serverLastLabel.className = 'meta-muted';
serverLastLabel.textContent = 'Крайний блок: —';
const serverLastHashLabel = document.createElement('p');
serverLastHashLabel.className = 'meta-muted';
serverLastHashLabel.style.wordBreak = 'break-all';
serverLastHashLabel.style.fontSize = '11px';
serverLastHashLabel.textContent = 'Hash: —';
const solanaTitle = document.createElement('h3');
solanaTitle.style.margin = '14px 0 0';
solanaTitle.textContent = 'Закреплено в Solana';
const solanaLastLabel = document.createElement('p');
solanaLastLabel.className = 'meta-muted';
solanaLastLabel.textContent = 'Крайний блок: —';
const solanaLastHashLabel = document.createElement('p');
solanaLastHashLabel.className = 'meta-muted';
solanaLastHashLabel.style.wordBreak = 'break-all';
solanaLastHashLabel.style.fontSize = '11px';
solanaLastHashLabel.textContent = 'Hash: —';
card.append(
limitLabel,
limitValue,
usedLabel,
usedValue,
leftLabel,
leftValue,
pdaLabel,
endpointLabel,
updatedLabel,
serverTitle,
serverSizeLabel,
serverLastLabel,
serverLastHashLabel,
solanaTitle,
solanaLastLabel,
solanaLastHashLabel,
);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
<button class="primary-btn" id="sync-shine-solana" style="width:100%;">Закрепить в Solana</button>
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
`;
const refreshBtn = actions.querySelector('#refresh-shine-bch');
const syncBtn = actions.querySelector('#sync-shine-solana');
const topupBtn = actions.querySelector('#topup-shine-limit');
const fetchServerState = async () => {
const user = await authService.getUser(String(state.session.login || '').trim());
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
const lastNumber = Number(user.serverLastGlobalNumber ?? -1);
return {
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber,
lastHash: String(user.serverLastGlobalHash || ''),
};
};
const resolveWalletSigningMaterial = async () => {
const { login, storagePwd } = sessionArgsOrThrow();
let saved;
try {
saved = await loadEncryptedUserSecrets(login, storagePwd);
} catch {
saved = null;
}
let rootKey = String(saved?.rootKey || '').trim();
let blockchainKey = String(saved?.blockchainKey || '').trim();
const clientKey = String(saved?.clientKey || '').trim();
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
}
const password = window.prompt(
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
'',
);
if (password == null) throw new Error('Операция отменена пользователем');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
const shouldSave = window.confirm(
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
);
if (shouldSave) {
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
}
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
};
const setButtonsDisabled = (disabled) => {
refreshBtn.disabled = disabled;
syncBtn.disabled = disabled;
topupBtn.disabled = disabled;
};
const refreshUsage = async () => {
setButtonsDisabled(true);
try {
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login: String(state.session.login || '').trim(),
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerState(),
]);
if (modeToken !== activeModeToken) return;
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
const usedServerBytes = BigInt(Math.max(0, Number(serverState.sizeBytes || 0)));
const leftServerBytes = usage.paidLimitBytes > usedServerBytes ? (usage.paidLimitBytes - usedServerBytes) : 0n;
usedValue.textContent = formatKbFromBytes(usedServerBytes);
leftValue.textContent = formatKbFromBytes(leftServerBytes);
pdaLabel.textContent = `PDA: ${usage.userPda}`;
endpointLabel.textContent = `RPC: ${usage.endpoint}`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
serverSizeLabel.textContent = `Размер цепочки: ${formatKbFromBytes(serverState.sizeBytes)}`;
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
setStatus('Данные лимита и состояния блокчейна обновлены.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось обновить состояние блокчейна: ${error?.message || 'unknown'}`);
} finally {
setButtonsDisabled(false);
}
};
syncBtn.addEventListener('click', async () => {
setButtonsDisabled(true);
try {
const [serverState, signing] = await Promise.all([
fetchServerState(),
resolveWalletSigningMaterial(),
]);
const result = await updateShineUserPdaOnSolana({
login: String(state.session.login || '').trim(),
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: 0n,
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
nextLastBlockNumber: serverState.lastNumber,
nextLastBlockHashHex: serverState.lastHash,
});
if (modeToken !== activeModeToken) return;
setStatus(`Состояние закреплено в Solana. Tx: ${result.signature}`);
await refreshUsage();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось закрепить состояние в Solana: ${error?.message || 'unknown'}`);
} finally {
setButtonsDisabled(false);
}
});
topupBtn.addEventListener('click', async () => {
setButtonsDisabled(true);
try {
const economy = await getShineUsersEconomyConfig({ solanaEndpoint: state.entrySettings.solanaServer });
const step = getLimitStepBytes();
const input = window.prompt(
`Введите, на сколько увеличить лимит (в байтах, шаг ${step.toString()}).\nЦена за шаг: ${lamportsToSolText(economy.lamportsPerLimitStep)} SOL`,
step.toString(),
);
if (!input) {
setStatus('Увеличение лимита отменено.');
return;
}
const addBytes = BigInt(String(input).trim());
const priceLamports = calcLimitTopupPriceLamports(addBytes, economy.lamportsPerLimitStep);
const confirm = window.confirm(
`Будет увеличено на ${formatKbFromBytes(addBytes)}.\n` +
`С вашего Solana-счёта будет списано ~${lamportsToSolText(priceLamports)} SOL.\n` +
`Продолжить?`,
);
if (!confirm) {
setStatus('Увеличение лимита отменено.');
return;
}
const signing = await resolveWalletSigningMaterial();
const result = await updateShineUserPdaOnSolana({
login: String(state.session.login || '').trim(),
solanaEndpoint: state.entrySettings.solanaServer,
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
additionalLimitBytes: addBytes,
});
if (modeToken !== activeModeToken) return;
setStatus(`Лимит увеличен. Tx: ${result.signature}`);
await refreshUsage();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось увеличить лимит: ${error?.message || 'unknown'}`);
} finally {
setButtonsDisabled(false);
}
});
refreshBtn.addEventListener('click', () => {
void refreshUsage();
});
content.append(backBtn, card, actions);
setStatus('Загрузка данных блокчейна Сияния...');
await refreshUsage();
}
async function renderSolanaWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
let walletCtx = null;
let walletAddress = '';
const backBtn = createModeBackButton(renderWalletChoice);
const card = document.createElement('div');
card.className = 'card stack';
const balanceWrap = document.createElement('div');
const balanceLabel = document.createElement('p');
balanceLabel.className = 'meta-muted';
balanceLabel.textContent = 'Баланс (Solana)';
const balanceValue = document.createElement('h2');
balanceValue.style.fontSize = '30px';
balanceValue.textContent = '— SOL';
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
const endpointLabel = document.createElement('p');
endpointLabel.className = 'meta-muted';
endpointLabel.textContent = `RPC: ${state.entrySettings.solanaServer}`;
balanceWrap.append(balanceLabel, balanceValue, updatedLabel, endpointLabel);
const addressCard = document.createElement('div');
addressCard.className = 'card';
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (client.key)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
card.append(balanceWrap, addressCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<div class="row">
<button class="text-btn" id="copy-address">Копировать адрес</button>
<button class="ghost-btn" id="refresh-balance">Обновить баланс</button>
</div>
<div class="row">
<button class="primary-btn" id="send-sol" style="width:100%;">Перевести</button>
<button class="primary-btn" id="topup-sol" style="width:100%;">Пополнить</button>
</div>
`;
const copyBtn = actions.querySelector('#copy-address');
const refreshBtn = actions.querySelector('#refresh-balance');
const sendBtn = actions.querySelector('#send-sol');
const topupBtn = actions.querySelector('#topup-sol');
const generatedCard = document.createElement('div');
generatedCard.className = 'card stack';
generatedCard.innerHTML = `
<h3 style="margin:0;">Создание нового кошелька Solana</h3>
<p class="meta-muted" style="margin:0;">Введите приватный ключ Base58 (32 байта) или сгенерируйте случайный.</p>
`;
const privateLabel = document.createElement('label');
privateLabel.className = 'meta-muted';
privateLabel.textContent = 'Приватный ключ (Base58, 32 байта)';
privateLabel.setAttribute('for', 'solana-private-base58-input');
const privateInput = document.createElement('input');
privateInput.id = 'solana-private-base58-input';
privateInput.type = 'text';
privateInput.placeholder = 'Введите приватный ключ Base58';
privateInput.maxLength = SOLANA_PRIVATE_BASE58_MAX_LEN;
privateInput.autocomplete = 'off';
privateInput.spellcheck = false;
const privateState = document.createElement('p');
privateState.className = 'meta-muted';
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
const generatedPublicLabel = document.createElement('label');
generatedPublicLabel.className = 'meta-muted';
generatedPublicLabel.textContent = 'Публичный ключ (Base58)';
generatedPublicLabel.setAttribute('for', 'solana-generated-public-key');
const generatedPublicInput = document.createElement('input');
generatedPublicInput.id = 'solana-generated-public-key';
generatedPublicInput.type = 'text';
generatedPublicInput.readOnly = true;
generatedPublicInput.placeholder = 'Будет сгенерирован после нажатия кнопки';
const generatedPrivateLabel = document.createElement('label');
generatedPrivateLabel.className = 'meta-muted';
generatedPrivateLabel.textContent = 'Сгенерированный приватный ключ (Base58)';
generatedPrivateLabel.setAttribute('for', 'solana-generated-private-key');
const generatedPrivateInput = document.createElement('input');
generatedPrivateInput.id = 'solana-generated-private-key';
generatedPrivateInput.type = 'text';
generatedPrivateInput.readOnly = true;
generatedPrivateInput.placeholder = 'Появится после генерации';
const generationActions = document.createElement('div');
generationActions.className = 'row';
generationActions.innerHTML = `
<button class="primary-btn" id="generate-random-solana" style="width:100%;">Сгенерировать случайный кошелёк</button>
<button class="primary-btn" id="generate-from-private-solana" style="width:100%;">Сгенерировать из приватного ключа</button>
`;
const copyGeneratedActions = document.createElement('div');
copyGeneratedActions.className = 'row';
copyGeneratedActions.innerHTML = `
<button class="text-btn" id="copy-generated-private-solana" style="width:100%;">Копировать приватный</button>
<button class="text-btn" id="copy-generated-public-solana" style="width:100%;">Копировать публичный</button>
`;
generatedCard.append(
privateLabel,
privateInput,
privateState,
generationActions,
generatedPrivateLabel,
generatedPrivateInput,
generatedPublicLabel,
generatedPublicInput,
copyGeneratedActions,
);
const randomGenerateBtn = generationActions.querySelector('#generate-random-solana');
const fromPrivateGenerateBtn = generationActions.querySelector('#generate-from-private-solana');
const copyGeneratedPrivateBtn = copyGeneratedActions.querySelector('#copy-generated-private-solana');
const copyGeneratedPublicBtn = copyGeneratedActions.querySelector('#copy-generated-public-solana');
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]+$/;
const validatePrivateInput = () => {
const value = String(privateInput.value || '').trim();
if (!value) {
privateState.textContent = 'Ожидается Base58-строка приватного ключа.';
return false;
}
if (!BASE58_RE.test(value)) {
privateState.textContent = 'Недопустимый формат: используйте только Base58.';
return false;
}
if (value.length > SOLANA_PRIVATE_BASE58_MAX_LEN) {
privateState.textContent = 'Слишком длинное значение.';
return false;
}
try {
const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let num = 0n;
for (const c of value) {
num = num * 58n + BigInt(alphabet.indexOf(c));
}
let hex = num.toString(16);
if (hex.length % 2) hex = `0${hex}`;
const decoded = hex ? hex.match(/.{1,2}/g)?.map((h) => parseInt(h, 16)) || [] : [];
let leadingZeros = 0;
while (leadingZeros < value.length && value[leadingZeros] === '1') leadingZeros += 1;
const byteLen = leadingZeros + decoded.length;
if (byteLen < 32) {
privateState.textContent = 'Слишком короткое значение: нужно 32 байта.';
return false;
}
if (byteLen > 32) {
privateState.textContent = 'Слишком длинное значение: нужно ровно 32 байта.';
return false;
}
} catch {
privateState.textContent = 'Ошибка декодирования Base58.';
return false;
}
privateState.textContent = 'Подходит';
return true;
};
privateInput.addEventListener('input', () => {
validatePrivateInput();
});
const setGenerationDisabled = (disabled) => {
randomGenerateBtn.disabled = disabled;
fromPrivateGenerateBtn.disabled = disabled;
copyGeneratedPrivateBtn.disabled = disabled;
copyGeneratedPublicBtn.disabled = disabled;
};
randomGenerateBtn.addEventListener('click', async () => {
setGenerationDisabled(true);
try {
const generated = await createRandomSolanaWallet();
if (modeToken !== activeModeToken) return;
generatedPrivateInput.value = generated.privateKey32Base58;
generatedPublicInput.value = generated.address;
privateState.textContent = 'Случайный кошелёк создан.';
setStatus('Случайный кошелёк Solana успешно сгенерирован.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка генерации случайного кошелька: ${error?.message || 'unknown'}`);
} finally {
setGenerationDisabled(false);
}
});
fromPrivateGenerateBtn.addEventListener('click', async () => {
if (!validatePrivateInput()) {
setStatus('Исправьте приватный ключ перед генерацией.');
return;
}
setGenerationDisabled(true);
try {
const generated = await createSolanaWalletFromPrivateBase58(privateInput.value);
if (modeToken !== activeModeToken) return;
generatedPrivateInput.value = generated.privateKey32Base58;
generatedPublicInput.value = generated.address;
privateState.textContent = 'Подходит';
setStatus('Публичный ключ сгенерирован из введённого приватного ключа.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка генерации из приватного ключа: ${error?.message || 'unknown'}`);
} finally {
setGenerationDisabled(false);
}
});
copyGeneratedPrivateBtn.addEventListener('click', async () => {
const value = String(generatedPrivateInput.value || '').trim();
if (!value) {
setStatus('Сначала сгенерируйте приватный ключ.');
return;
}
try {
await navigator.clipboard.writeText(value);
setStatus('Приватный ключ скопирован.');
} catch {
setStatus('Не удалось скопировать приватный ключ в этом браузере.');
}
});
copyGeneratedPublicBtn.addEventListener('click', async () => {
const value = String(generatedPublicInput.value || '').trim();
if (!value) {
setStatus('Сначала сгенерируйте публичный ключ.');
return;
}
try {
await navigator.clipboard.writeText(value);
setStatus('Публичный ключ скопирован.');
} catch {
setStatus('Не удалось скопировать публичный ключ в этом браузере.');
}
});
const refreshBalance = async () => {
if (!walletAddress) {
setStatus('Кошелёк не инициализирован.');
return;
}
refreshBtn.disabled = true;
try {
const balance = await getBalanceSol({
endpoint: state.entrySettings.solanaServer,
address: walletAddress,
});
if (modeToken !== activeModeToken) return;
balanceValue.textContent = `${formatSol(balance.sol, 6)} SOL`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
endpointLabel.textContent = `RPC: ${balance.endpoint}`;
setStatus('Баланс обновлён.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
}
};
copyBtn.addEventListener('click', async () => {
if (!walletAddress) return;
try {
await navigator.clipboard.writeText(walletAddress);
setStatus('Адрес скопирован в буфер обмена');
} catch {
setStatus('Не удалось скопировать адрес в этом браузере');
}
});
refreshBtn.addEventListener('click', () => {
void refreshBalance();
});
sendBtn.addEventListener('click', async () => {
if (!walletCtx?.keypair) {
setStatus('Перевод недоступен: client.key не загружен.');
return;
}
const toAddress = window.prompt('Введите адрес получателя (Solana):', '');
if (!toAddress) return;
const amountRaw = window.prompt('Введите сумму SOL для перевода:', '0.01');
if (!amountRaw) return;
sendBtn.disabled = true;
try {
const tx = await transferSol({
endpoint: state.entrySettings.solanaServer,
fromKeypair: walletCtx.keypair,
toAddress,
amountSol: Number(String(amountRaw || '').replace(',', '.')),
});
if (modeToken !== activeModeToken) return;
setStatus(`Перевод отправлен. Signature: ${tx.signature}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`);
} finally {
sendBtn.disabled = false;
}
});
topupBtn.addEventListener('click', async () => {
if (!walletAddress) {
setStatus('Кошелёк не инициализирован.');
return;
}
window.location.assign(getTopupSiteUrl(walletAddress));
});
content.append(backBtn, card, actions, generatedCard);
setStatus('Инициализация client.key...');
try {
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
if (modeToken !== activeModeToken) return;
walletAddress = walletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'client.key недоступен';
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
}
}
async function renderArweaveWallet() {
const modeToken = ++activeModeToken;
content.innerHTML = '';
let walletAddress = '';
const backBtn = createModeBackButton(renderWalletChoice);
const card = document.createElement('div');
card.className = 'card stack';
const balanceWrap = document.createElement('div');
const balanceLabel = document.createElement('p');
balanceLabel.className = 'meta-muted';
balanceLabel.textContent = 'Баланс (Arweave)';
const balanceValue = document.createElement('h2');
balanceValue.style.fontSize = '30px';
balanceValue.textContent = '— AR';
const updatedLabel = document.createElement('p');
updatedLabel.className = 'meta-muted';
updatedLabel.textContent = 'Обновлено: —';
const gatewayLabel = document.createElement('p');
gatewayLabel.className = 'meta-muted';
gatewayLabel.textContent = `Gateway: ${state.entrySettings.arweaveServer}`;
balanceWrap.append(balanceLabel, balanceValue, updatedLabel, gatewayLabel);
const addressCard = document.createElement('div');
addressCard.className = 'card';
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес Arweave (SAWD-v1)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
const helpCard = document.createElement('details');
helpCard.className = 'card';
helpCard.style.padding = '10px';
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;
card.append(balanceWrap, addressCard, helpCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<div class="row">
<button class="text-btn" id="copy-address">Копировать адрес</button>
<button class="ghost-btn" id="refresh-balance">Обновить баланс</button>
</div>
<div class="row">
<button class="primary-btn" id="send-ar" style="width:100%;">Перевести</button>
<button class="primary-btn" id="topup-ar" style="width:100%;">Пополнить</button>
</div>
`;
const copyBtn = actions.querySelector('#copy-address');
const refreshBtn = actions.querySelector('#refresh-balance');
const sendBtn = actions.querySelector('#send-ar');
const topupBtn = actions.querySelector('#topup-ar');
const refreshBalance = async () => {
if (!walletAddress) {
setStatus('Arweave-кошелёк не инициализирован.');
return;
}
refreshBtn.disabled = true;
try {
const balance = await getArweaveBalance({
gateway: state.entrySettings.arweaveServer,
address: walletAddress,
});
if (modeToken !== activeModeToken) return;
balanceValue.textContent = `${formatAr(balance.ar, 6)} AR`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
gatewayLabel.textContent = `Gateway: ${balance.gateway}`;
setStatus('Баланс обновлён.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось получить баланс: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
}
};
copyBtn.addEventListener('click', async () => {
if (!walletAddress) return;
try {
await navigator.clipboard.writeText(walletAddress);
setStatus('Адрес скопирован');
} catch {
setStatus('Не удалось скопировать адрес в этом браузере');
}
});
refreshBtn.addEventListener('click', () => {
void refreshBalance();
});
sendBtn.addEventListener('click', async () => {
if (!arweaveWalletCtx?.jwk) {
setStatus('Перевод недоступен: Arweave-кошелёк не инициализирован.');
return;
}
const toAddress = window.prompt('Введите адрес получателя (Arweave):', '');
if (!toAddress) return;
const amountRaw = window.prompt('Введите сумму AR для перевода:', '0.01');
if (!amountRaw) return;
sendBtn.disabled = true;
try {
const tx = await transferAr({
gateway: state.entrySettings.arweaveServer,
jwk: arweaveWalletCtx.jwk,
toAddress,
amountAr: amountRaw,
});
if (modeToken !== activeModeToken) return;
setStatus(`Перевод отправлен. Transaction ID: ${tx.id}`);
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Ошибка перевода: ${error?.message || 'unknown'}`);
} finally {
sendBtn.disabled = false;
}
});
topupBtn.addEventListener('click', () => {
window.open(getArweaveTopupSiteUrl(), '_blank', 'noopener,noreferrer');
setStatus('Открыта страница пополнения.');
});
content.append(backBtn, card, actions);
setStatus('Генерация Arweave-кошелька...');
try {
let wasFirstTimeGeneration = false;
arweaveWalletCtx = await getArweaveWalletFromStoredClientKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
wasFirstTimeGeneration = true;
setStatus('Подождите — ваш Arweave-ключ вычисляется из client key. Это происходит только один раз, потом будет мгновенно.');
return;
}
setStatus(text);
},
});
if (modeToken !== activeModeToken) return;
if (wasFirstTimeGeneration) setStatus('');
walletAddress = arweaveWalletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'client.key недоступен';
clearArweaveSecretsInMemory();
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
}
}
renderWalletChoice();
return screen;
}