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 = `
Поддержать проект Сияние
Здесь можно купить билет, посмотреть очередь и увидеть, как устроена покупка.
Оплата идет в SOL, а сумма считается по курсу USD/USDT на момент транзакции.
Номер билета и секретный ключ кошелька нужно сохранить отдельно.
`;
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
Купить билет
Посмотреть очередь
Сгенерировать новую пару ключей
`;
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 = `
Справка по покупке билета
${SUPPORT_TICKET_HELP_TEXT}
После покупки билет остается в очереди 1. Позже можно открыть экран просмотра и проверить, сколько билетов и сумм уже прошло перед ним.
`;
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 = `
Сгенерировать новую пару ключей
Генерация использует безопасный рандом браузера, а ваш дополнительный текст и текущее время добавляются как примесь.
Вводить текст необязательно: даже без него ключи остаются случайными.
`;
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 = `
Сгенерировать пару ключей
Копировать public key
Копировать secret key
Скачать ключи
`;
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 = `
Посмотреть очередь билета
Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате 2-15 или 3 8.
Экран покажет, сколько билетов перед ним уже стоит, сколько уже выплачено и кому принадлежит билет.
`;
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 = `
Показать билет
Сбросить
`;
const result = document.createElement('div');
result.className = 'card stack';
result.innerHTML = `
Введите номер билета и нажмите кнопку.
`;
let lastCoreState = null;
const renderTicketInfo = async () => {
const raw = String(queryInput.value || '').trim();
if (!raw) {
result.innerHTML = `Введите номер билета.
`;
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 = `
Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.
`;
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 = `
Билет: ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}
Статус: ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}
Получатель: ${ticket.recipientWallet}
До него в очереди: ${ticketsBefore.toString()} билетов
Из них уже выплачено: ${paidBefore.toString()} билетов
Ещё осталось до него: ${remainingBefore.toString()} билетов
Уже выплачено по сумме в очереди: ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD
Сумма этого билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
Накоплено до этого билета: ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него
`;
setStatus(`Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`);
} catch (error) {
result.innerHTML = `${String(error?.message || 'Не удалось загрузить билет')}
`;
setStatus(`Не удалось посмотреть билет: ${error?.message || 'unknown'}`);
}
};
actions.querySelector('#support-ticket-load')?.addEventListener('click', () => {
void renderTicketInfo();
});
actions.querySelector('#support-ticket-reset')?.addEventListener('click', () => {
queryInput.value = '';
result.innerHTML = `Введите номер билета и нажмите кнопку.
`;
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 = `
Купить билет
Вы покупаете только билет очереди 1. Сумма задается в USD, а оплата уходит в SOL по курсу USDT/SOL на момент транзакции.
Покупка подписывается вашим client key. Интерфейс проверяет допуск по курсу до 3 процентов и не дает выходить за текущий лимит по коэффициенту.
`;
const stateCard = document.createElement('div');
stateCard.className = 'card stack';
stateCard.innerHTML = `
Текущее состояние покупки загружается...
`;
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 = `
На тот же кошелёк, с которого покупаем
`;
const quoteCard = document.createElement('div');
quoteCard.className = 'card stack';
quoteCard.innerHTML = `Расчет появится после загрузки курса.
`;
const purchaseResultCard = document.createElement('div');
purchaseResultCard.className = 'card stack';
purchaseResultCard.innerHTML = `
После покупки здесь появится номер билета и краткий итог.
`;
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
Купить
Обновить условия
Справка
`;
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 = `
Коэффициент сейчас: ${currentCoef}
В очереди перед вами уже куплено: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
Осталось до лимита текущего коэффициента: ${formatUsdCentsText(remainingUsdCents)} USD
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
Примерная цена: ${amountSol} SOL
Максимум при допуске 3%: ${maxPaySol} SOL
${canBuy ? 'Покупка укладывается в текущий лимит.' : 'Сумма сейчас не укладывается в текущий лимит или введена некорректно.'}
Если текущий лимит заполнится, откроется новый лимит с более низким коэффициентом.
Покупка идет в SOL, но считается по курсу USD/USDT на момент транзакции.
`;
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 = `
Коэффициент: ${formatPpmCoefText(currentCore.coef.coefPpm)}
Лимит текущего коэффициента: ${formatUsdCentsText(currentCore.coef.limitUsdCents)} USD
Уже куплено в очереди 1: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
Осталось купить по текущему коэффициенту: ${formatUsdCentsText(remainingUsdCents)} USD
Сколько билетов уже в очереди: ${queue.ticketsTotal.toString()}
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
Курс: 1 SOL = ${formatPythSolUsdText(currentCore.pyth)} USD
После заполнения лимита будет новый лимит, но уже с более низким коэффициентом.
`;
setRecipientFromWallet();
updateQuote();
setStatus('Условия покупки обновлены.');
} catch (error) {
if (modeToken !== activeModeToken) return;
stateCard.innerHTML = `${String(error?.message || 'Не удалось загрузить состояние')}
`;
quoteCard.innerHTML = `${String(error?.message || 'Не удалось рассчитать цену')}
`;
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 = `
Покупка завершена: билет ${formatQueuePrefix(ticket.queueId)}-${ticket.index.toString()}
Получатель: ${ticket.recipientWallet}
Сумма билета: ${formatUsdCentsText(ticket.payoutUsdCents)} USD
До него было в очереди: ${ticket.index > 0n ? (ticket.index - 1n).toString() : '0'} билетов
Транзакция: ${signature}
`;
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 = `
Кошелёк
Выберите режим кошелька.
`;
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 = `
Обновить
Закрепить в Solana
Увеличить лимит
`;
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 = `
Публичный адрес (client.key)
—
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
card.append(balanceWrap, addressCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
Копировать адрес
Обновить баланс
Перевести
Пополнить
`;
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 = `
Создание нового кошелька Solana
Введите приватный ключ Base58 (32 байта) или сгенерируйте случайный.
`;
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 = `
Сгенерировать случайный кошелёк
Сгенерировать из приватного ключа
`;
const copyGeneratedActions = document.createElement('div');
copyGeneratedActions.className = 'row';
copyGeneratedActions.innerHTML = `
Копировать приватный
Копировать публичный
`;
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 = `
Публичный адрес Arweave (SAWD-v1)
—
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
const helpCard = document.createElement('details');
helpCard.className = 'card';
helpCard.style.padding = '10px';
helpCard.innerHTML = `
Как получен этот адрес?
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
`;
card.append(balanceWrap, addressCard, helpCard);
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
Копировать адрес
Обновить баланс
Перевести
Пополнить
`;
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;
}