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

2185 lines
96 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 { defaultSolanaCluster } from '../deploy-config.js';
import { authService, state } from '../state.js';
import {
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 { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { buildArweaveDataUrl, sha256HexFromArrayBuffer } from '../services/arweave-file-service.js';
import {
calcLimitTopupPriceLamports,
getLimitStepBytes,
getShineBlockchainUsage,
getShineUsersEconomyConfig,
updateShineUserPdaOnSolana,
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
import {
buildBlockchainSnapshotFile,
clearStoredBlockchainSnapshot,
readStoredBlockchainSnapshot,
saveStoredBlockchainSnapshot,
} from '../services/shine-blockchain-snapshot-service.js';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
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 formatBytesRu(rawBytes) {
const bytes = typeof rawBytes === 'bigint' ? Number(rawBytes) : Number(rawBytes || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
return bytes.toLocaleString('ru-RU');
}
function normalizeHex64(value) {
const raw = String(value || '').trim().toLowerCase();
return /^[0-9a-f]{64}$/.test(raw) ? raw : '';
}
function lamportsToSolText(lamportsBigInt) {
const value = Number(lamportsBigInt || 0n) / 1_000_000_000;
return value.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 9 });
}
function formatLimitTopupError(error) {
const message = String(error?.message || '').trim();
const logs = Array.isArray(error?.logs) ? error.logs.join(' | ') : '';
const haystack = `${message} ${logs}`.toLowerCase();
if (haystack.includes('insufficient funds for rent')) {
return 'Недостаточно средств на Solana-кошельке для пополнения лимита.';
}
return `Не удалось увеличить лимит: ${message || 'unknown'}`;
}
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 = 'SHiPmXbM9Fs9khzRUW3TGKsS2W84aqaXTxs3ZkajW9v';
const SHINE_PAYMENTS_ORACLE_ACCOUNT = '7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE';
const SHINE_MAINNET_SOLANA_ENDPOINT = 'https://solana-rpc.publicnode.com';
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 isTestDeployUi() {
return String(defaultSolanaCluster || '').trim().toLowerCase() !== 'mainnet-beta';
}
function resolveSupportQueueEndpoint(useMainnetQueue) {
return useMainnetQueue ? SHINE_MAINNET_SOLANA_ENDPOINT : state.entrySettings.solanaServer;
}
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;
}
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function encodeBase58(bytes) {
const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
if (source.length === 0) return '';
const digits = [0];
for (let i = 0; i < source.length; i += 1) {
let carry = source[i];
for (let j = 0; j < digits.length; j += 1) {
const value = (digits[j] << 8) + carry;
digits[j] = value % 58;
carry = Math.floor(value / 58);
}
while (carry > 0) {
digits.push(carry % 58);
carry = Math.floor(carry / 58);
}
}
for (let i = 0; i < source.length && source[i] === 0; i += 1) {
digits.push(0);
}
return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join('');
}
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 formatQueueTitle(queueId) {
if (queueId === 2) return 'Вторая очередь';
if (queueId === 3) return 'Третья очередь';
return 'Первая очередь';
}
function formatSupportTicketLabel(queueId, index) {
const cleanIndex = BigInt(index || 0n).toString();
if (queueId === 2 || queueId === 3) return `${queueId}/${cleanIndex}`;
return cleanIndex;
}
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(/^([23])\s*\/\s*(\d+)$/);
if (!match) {
throw new Error('Формат: для первой очереди просто номер, для второй и третьей - например 2/7 или 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: encodeBase58(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;
}
async function fetchServerBlockchainState() {
const login = String(state.session.login || '').trim();
const user = await authService.getUser(login);
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
return {
login,
blockchainName: String(user.blockchainName || `${login}-001`).trim(),
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
lastHash: String(user.serverLastGlobalHash || '').trim().toLowerCase(),
};
}
async function resolveWalletSigningMaterial() {
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 };
}
function styleSupportInputField(field) {
if (!field) return;
field.style.color = '#111111';
field.style.webkitTextFillColor = '#111111';
field.style.caretColor = '#111111';
field.style.backgroundColor = '#ffffff';
}
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 renderSupportBuyIntro();
});
actions.querySelector('#support-queue')?.addEventListener('click', () => {
navigate('quene');
});
actions.querySelector('#support-keygen')?.addEventListener('click', () => {
void renderSupportKeygen();
});
content.append(backBtn, intro, actions);
setStatus('Выберите действие в разделе поддержки.');
}
async function renderSupportHelp(backTarget = renderSupportBuyForm) {
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;
styleSupportInputField(saltInput);
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 = 'Появится после генерации';
styleSupportInputField(generatedPublicInput);
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;
styleSupportInputField(generatedSecretInput);
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;">
Введите номер билета.
</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;
styleSupportInputField(queryInput);
const testDeploy = isTestDeployUi();
const mainnetRow = document.createElement('label');
mainnetRow.className = 'checkbox-row';
mainnetRow.innerHTML = `
<input type="checkbox" id="support-ticket-mainnet-source" ${testDeploy ? 'checked' : ''} />
<span>Показывать данные mainnet (${SHINE_MAINNET_SOLANA_ENDPOINT})</span>
`;
mainnetRow.style.display = testDeploy ? '' : 'none';
const mainnetCheckbox = mainnetRow.querySelector('#support-ticket-mainnet-source');
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;
let lastEndpoint = '';
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 endpoint = resolveSupportQueueEndpoint(Boolean(testDeploy && mainnetCheckbox?.checked));
const core = lastCoreState && lastEndpoint === endpoint
? lastCoreState
: await loadSupportPaymentsCore(endpoint);
lastCoreState = core;
lastEndpoint = endpoint;
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;">Билет ${formatSupportTicketLabel(parsed.queueId, parsed.index)} пока не создан.</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;
if (ticket.queueId === 2 || ticket.queueId === 3) {
result.innerHTML = `
<div>${formatQueueTitle(ticket.queueId)}</div>
<div><b>Номер билета:</b> ${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> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
`;
} else {
result.innerHTML = `
<div><b>Билет:</b> ${formatSupportTicketLabel(parsed.queueId, ticket.index)}</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(`Билет ${formatSupportTicketLabel(ticket.queueId, ticket.index)} найден. Источник Solana: ${endpoint}`);
} 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('Поле билета очищено.');
});
mainnetCheckbox?.addEventListener('change', () => {
lastCoreState = null;
lastEndpoint = '';
setStatus(`Источник Solana: ${resolveSupportQueueEndpoint(Boolean(testDeploy && mainnetCheckbox.checked))}`);
});
content.append(backBtn, card, mainnetRow, inputLabel, queryInput, actions, result);
setStatus(`Просмотр билета готов. Источник Solana: ${resolveSupportQueueEndpoint(Boolean(testDeploy && mainnetCheckbox?.checked))}`);
}
async function renderSupportBuyIntro() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const backBtn = createModeBackButton(renderSupportHub);
const introCard = document.createElement('div');
introCard.className = 'card stack';
introCard.innerHTML = `
<h2 style="margin:0;">Купить билет</h2>
<p class="meta-muted" style="margin:0; line-height:1.55;">
Сначала показываем условия и текущий лимит. После этого можно перейти к покупке и ввести сумму в долларах.
Оплата идет в SOL, а расчет строится по курсу USD/USDT на момент транзакции.
</p>
`;
const stateCard = document.createElement('div');
stateCard.className = 'card stack';
stateCard.innerHTML = `<p class="meta-muted" style="margin:0;">Загрузка условий...</p>`;
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<button class="primary-btn" type="button" id="support-buy-next" style="width:100%;">Купить на сумму в долларах</button>
<button class="text-btn" type="button" id="support-buy-help" style="width:100%;">Справка</button>
`;
const nextBtn = actions.querySelector('#support-buy-next');
const helpBtn = actions.querySelector('#support-buy-help');
nextBtn.disabled = true;
let currentCore = null;
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>
`;
nextBtn.disabled = false;
setStatus('Условия покупки загружены. Можно переходить к форме покупки.');
} catch (error) {
if (modeToken !== activeModeToken) return;
stateCard.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить состояние')}</p>`;
nextBtn.disabled = true;
setStatus(`Не удалось загрузить условия покупки: ${error?.message || 'unknown'}`);
}
nextBtn?.addEventListener('click', () => {
void renderSupportBuyForm();
});
helpBtn?.addEventListener('click', () => {
void renderSupportHelp(renderSupportBuyIntro);
});
content.append(backBtn, introCard, stateCard, actions);
}
async function renderSupportBuyForm() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const backBtn = createModeBackButton(renderSupportBuyIntro);
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;">
Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата.
Покупка подписывается вашим client key и отклоняется, если курс уходит дальше допустимого порога.
</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';
styleSupportInputField(amountInput);
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;
styleSupportInputField(recipientInput);
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;
}
styleSupportInputField(recipientInput);
};
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(renderSupportBuyForm);
});
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 solanaPublishBtn = document.createElement('button');
solanaPublishBtn.className = 'primary-btn';
solanaPublishBtn.style.width = '100%';
solanaPublishBtn.textContent = 'Закрепление в Solana';
solanaPublishBtn.addEventListener('click', () => {
void renderSolanaPublishWallet();
});
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, solanaPublishBtn, 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';
card.innerHTML = `
<h2 style="margin:0;">Предоплаченное место в блокчейне Сияния</h2>
`;
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: —';
card.append(
limitLabel,
limitValue,
usedLabel,
usedValue,
leftLabel,
leftValue,
pdaLabel,
endpointLabel,
updatedLabel,
serverTitle,
serverSizeLabel,
serverLastLabel,
serverLastHashLabel,
);
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="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
`;
const refreshBtn = actions.querySelector('#refresh-shine-bch');
const topupBtn = actions.querySelector('#topup-shine-limit');
const setButtonsDisabled = (disabled) => {
refreshBtn.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,
}),
fetchServerBlockchainState(),
]);
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 || '—'}`;
setStatus('Данные лимита и состояния блокчейна обновлены.');
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось обновить состояние блокчейна: ${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(formatLimitTopupError(error));
} finally {
setButtonsDisabled(false);
}
});
refreshBtn.addEventListener('click', () => {
void refreshUsage();
});
content.append(backBtn, card, actions);
setStatus('Загрузка данных блокчейна Сияния...');
await refreshUsage();
}
async function renderSolanaPublishWallet() {
const modeToken = ++activeModeToken;
clearArweaveSecretsInMemory();
content.innerHTML = '';
const login = String(state.session.login || '').trim();
const backBtn = createModeBackButton(renderWalletChoice);
const summaryCard = document.createElement('div');
summaryCard.className = 'card stack';
summaryCard.innerHTML = `
<h2 style="margin:0;">Закрепление в Solana</h2>
<p class="meta-muted" style="margin:0;">Сначала делаем полный слепок пользовательского блокчейна в Arweave или Turbo, затем обновляем ссылку в PDA.</p>
`;
const publicationStatus = document.createElement('div');
publicationStatus.className = 'card stack';
publicationStatus.innerHTML = `
<h3 style="margin:0;">Статус публикации</h3>
<p class="meta-muted" id="publish-status-main">—</p>
<p class="meta-muted" id="publish-status-local">—</p>
`;
const publicationMainEl = publicationStatus.querySelector('#publish-status-main');
const publicationLocalEl = publicationStatus.querySelector('#publish-status-local');
const pdaCard = document.createElement('div');
pdaCard.className = 'card stack';
pdaCard.innerHTML = `
<h3 style="margin:0;">Что закреплено в PDA</h3>
<p class="meta-muted" id="pda-name">Блокчейн: —</p>
<p class="meta-muted" id="pda-address" style="word-break:break-all;">PDA: —</p>
<p class="meta-muted" id="pda-last">Крайний блок: —</p>
<p class="meta-muted" id="pda-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="pda-used">Размер: —</p>
<p class="meta-muted" id="pda-limit">Лимит: —</p>
<p class="meta-muted" id="pda-arweave" style="word-break:break-all;">Arweave tx: —</p>
<a class="text-btn" id="pda-arweave-link" href="#" target="_blank" rel="noreferrer noopener" style="pointer-events:none; opacity:.55; padding:0;">Открыть Arweave</a>
`;
const serverCard = document.createElement('div');
serverCard.className = 'card stack';
serverCard.innerHTML = `
<h3 style="margin:0;">Что реально на сервере</h3>
<p class="meta-muted" id="server-name">Блокчейн: —</p>
<p class="meta-muted" id="server-last">Крайний блок: —</p>
<p class="meta-muted" id="server-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="server-size">Размер: —</p>
<p class="meta-muted" id="server-updated">Обновлено: —</p>
`;
const localCard = document.createElement('div');
localCard.className = 'card stack';
localCard.innerHTML = `
<h3 style="margin:0;">Локальный слепок в этом браузере</h3>
<p class="meta-muted" id="local-state">Слепок: —</p>
<p class="meta-muted" id="local-transport">Способ: —</p>
<p class="meta-muted" id="local-last">Крайний блок: —</p>
<p class="meta-muted" id="local-hash" style="word-break:break-all; font-size:11px;">Hash: —</p>
<p class="meta-muted" id="local-size">Размер: —</p>
<p class="meta-muted" id="local-arweave" style="word-break:break-all;">Arweave tx: —</p>
<p class="meta-muted" id="local-created">Создан: —</p>
<p class="meta-muted" id="local-solana">Solana: —</p>
`;
const actions = document.createElement('div');
actions.className = 'stack';
actions.innerHTML = `
<button class="ghost-btn" id="refresh-solana-publish" style="width:100%;">Обновить состояние</button>
<button class="primary-btn" id="create-solana-snapshot" style="width:100%;">Сделать слепок в Arweave / Turbo</button>
<button class="primary-btn" id="publish-solana-snapshot" style="width:100%;">Обновить Solana по готовому слепку</button>
<button class="text-btn" id="clear-solana-snapshot" style="width:100%;">Забыть локальный слепок в этом браузере</button>
`;
const refreshBtn = actions.querySelector('#refresh-solana-publish');
const createBtn = actions.querySelector('#create-solana-snapshot');
const publishBtn = actions.querySelector('#publish-solana-snapshot');
const clearBtn = actions.querySelector('#clear-solana-snapshot');
const pdaNameEl = pdaCard.querySelector('#pda-name');
const pdaAddressEl = pdaCard.querySelector('#pda-address');
const pdaLastEl = pdaCard.querySelector('#pda-last');
const pdaHashEl = pdaCard.querySelector('#pda-hash');
const pdaUsedEl = pdaCard.querySelector('#pda-used');
const pdaLimitEl = pdaCard.querySelector('#pda-limit');
const pdaArweaveEl = pdaCard.querySelector('#pda-arweave');
const pdaArweaveLinkEl = pdaCard.querySelector('#pda-arweave-link');
const serverNameEl = serverCard.querySelector('#server-name');
const serverLastEl = serverCard.querySelector('#server-last');
const serverHashEl = serverCard.querySelector('#server-hash');
const serverSizeEl = serverCard.querySelector('#server-size');
const serverUpdatedEl = serverCard.querySelector('#server-updated');
const localStateEl = localCard.querySelector('#local-state');
const localTransportEl = localCard.querySelector('#local-transport');
const localLastEl = localCard.querySelector('#local-last');
const localHashEl = localCard.querySelector('#local-hash');
const localSizeEl = localCard.querySelector('#local-size');
const localArweaveEl = localCard.querySelector('#local-arweave');
const localCreatedEl = localCard.querySelector('#local-created');
const localSolanaEl = localCard.querySelector('#local-solana');
function setButtonsDisabled(disabled) {
refreshBtn.disabled = disabled;
createBtn.disabled = disabled;
publishBtn.disabled = disabled;
clearBtn.disabled = disabled;
}
async function refreshState() {
setButtonsDisabled(true);
try {
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
]);
const localSnapshot = readStoredBlockchainSnapshot(login);
if (modeToken !== activeModeToken) return;
const pdaDelta = Math.max(0, Number(serverState.lastNumber || 0) - Number(usage.lastBlockNumber || 0));
if (!usage.arweaveTxId) {
publicationMainEl.textContent = 'не опубликовано';
} else if (pdaDelta > 0) {
publicationMainEl.textContent = `опубликовано, отстаёт на ${pdaDelta} блоков`;
} else {
publicationMainEl.textContent = 'опубликовано и полностью актуально';
}
let localStatusText = 'Локального слепка нет.';
const localMatchesServer = localSnapshot
&& String(localSnapshot.blockchainName || '') === String(usage.blockchainName || serverState.blockchainName || '')
&& Number(localSnapshot.lastBlockNumber) === Number(serverState.lastNumber)
&& normalizeHex64(localSnapshot.lastBlockHash) === normalizeHex64(serverState.lastHash)
&& Number(localSnapshot.usedBytes || 0) === Number(serverState.sizeBytes || 0);
if (localSnapshot?.txId && localMatchesServer) {
if (String(localSnapshot.txId || '') === String(usage.arweaveTxId || '')) {
localStatusText = 'Локальный слепок уже совпадает с тем, что закреплено в Solana.';
} else {
localStatusText = 'Слепок уже есть, но Solana ещё не обновлена. Лучше подождать 1-2 минуты и затем обновить PDA.';
}
} else if (localSnapshot?.txId) {
localStatusText = 'Есть локальный слепок, но он уже устарел относительно сервера.';
}
publicationLocalEl.textContent = localStatusText;
pdaNameEl.textContent = `Блокчейн: ${usage.blockchainName || '—'}`;
pdaAddressEl.textContent = `PDA: ${usage.userPda || '—'}`;
pdaLastEl.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
pdaHashEl.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
pdaUsedEl.textContent = `Размер: ${formatBytesRu(usage.usedBytes)} байт`;
pdaLimitEl.textContent = `Лимит: ${formatBytesRu(usage.paidLimitBytes)} байт`;
pdaArweaveEl.textContent = `Arweave tx: ${usage.arweaveTxId || '—'}`;
if (usage.arweaveTxId) {
pdaArweaveLinkEl.href = buildArweaveDataUrl({ gateway: state.entrySettings.arweaveServer, txId: usage.arweaveTxId });
pdaArweaveLinkEl.style.pointerEvents = 'auto';
pdaArweaveLinkEl.style.opacity = '1';
} else {
pdaArweaveLinkEl.href = '#';
pdaArweaveLinkEl.style.pointerEvents = 'none';
pdaArweaveLinkEl.style.opacity = '.55';
}
serverNameEl.textContent = `Блокчейн: ${serverState.blockchainName || usage.blockchainName || '—'}`;
serverLastEl.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverHashEl.textContent = `Hash: ${serverState.lastHash || '—'}`;
serverSizeEl.textContent = `Размер: ${formatBytesRu(serverState.sizeBytes)} байт`;
serverUpdatedEl.textContent = `Обновлено: ${nowRu()}`;
if (localSnapshot?.txId) {
localStateEl.textContent = `Слепок: ${String(localSnapshot.txId || '') === String(usage.arweaveTxId || '') ? 'закреплён' : 'есть, но не закреплён в Solana'}`;
localTransportEl.textContent = `Способ: ${localSnapshot.uploadTransport === 'turbo' ? 'Turbo' : (localSnapshot.uploadTransport === 'arweave' ? 'Arweave' : '—')}`;
localLastEl.textContent = `Крайний блок: ${localSnapshot.lastBlockNumber ?? '—'}`;
localHashEl.textContent = `Hash: ${localSnapshot.lastBlockHash || '—'}`;
localSizeEl.textContent = `Размер: ${formatBytesRu(localSnapshot.usedBytes || 0)} байт`;
localArweaveEl.textContent = `Arweave tx: ${localSnapshot.txId}`;
localCreatedEl.textContent = `Создан: ${localSnapshot.uploadedAtMs ? new Date(localSnapshot.uploadedAtMs).toLocaleString('ru-RU') : '—'}`;
localSolanaEl.textContent = localSnapshot.solanaUpdatedAtMs
? `Solana обновлена: ${new Date(localSnapshot.solanaUpdatedAtMs).toLocaleString('ru-RU')}`
: 'Solana: слепок ещё не закреплён';
} else {
localStateEl.textContent = 'Слепок: отсутствует';
localTransportEl.textContent = 'Способ: —';
localLastEl.textContent = 'Крайний блок: —';
localHashEl.textContent = 'Hash: —';
localSizeEl.textContent = 'Размер: —';
localArweaveEl.textContent = 'Arweave tx: —';
localCreatedEl.textContent = 'Создан: —';
localSolanaEl.textContent = 'Solana: —';
}
publishBtn.disabled = !localSnapshot?.txId || !localMatchesServer || String(localSnapshot.txId || '') === String(usage.arweaveTxId || '');
clearBtn.disabled = !localSnapshot;
setStatus('Состояние публикации обновлено.');
} catch (error) {
if (modeToken !== activeModeToken) return;
publishBtn.disabled = true;
setStatus(`Не удалось обновить состояние публикации: ${error?.message || 'unknown'}`);
} finally {
refreshBtn.disabled = false;
createBtn.disabled = false;
}
}
async function createSnapshot() {
setButtonsDisabled(true);
try {
setStatus('Собираю полный слепок блокчейна с сервера...');
const [usage, serverState] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
]);
const snapshotFile = await buildBlockchainSnapshotFile({
authService,
login,
blockchainName: usage.blockchainName || serverState.blockchainName,
lastBlockNumber: serverState.lastNumber,
});
const snapshotSha256 = await sha256HexFromArrayBuffer(await snapshotFile.file.arrayBuffer());
if (modeToken !== activeModeToken) return;
setStatus('Слепок собран. Открываю менеджер загрузки Arweave / Turbo...');
const uploaded = await openArweaveAttachmentManager({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
historyOnly: false,
persistToHistory: false,
allowHistorySelection: false,
allowExistingTxInput: false,
uploadTransport: 'turbo',
dialogTitle: 'Сделать слепок блокчейна',
uploadButtonLabel: 'Сделать слепок',
initialFile: snapshotFile.file,
initialSha256: snapshotSha256,
initialName: `${snapshotFile.blockchainName}.shine-blockchain`,
fixedFile: true,
autoOpenFileDialog: false,
shineType: 'blockchain-snapshot',
extraUploadTags: [
{ name: 'SHiNE-Blockchain-Snapshot', value: '1' },
{ name: 'SHiNE-Login', value: login },
{ name: 'SHiNE-Blockchain-Name', value: snapshotFile.blockchainName },
{ name: 'SHiNE-Last-Block-Number', value: String(serverState.lastNumber) },
{ name: 'SHiNE-Last-Block-Hash', value: String(serverState.lastHash || '') },
],
});
if (modeToken !== activeModeToken) return;
if (!uploaded?.ar) {
setStatus('Загрузка слепка отменена.');
return;
}
saveStoredBlockchainSnapshot(login, {
blockchainName: snapshotFile.blockchainName,
txId: String(uploaded.ar || '').trim(),
sha256: String(uploaded.sha256 || snapshotSha256).trim().toLowerCase(),
uploadTransport: String(uploaded.uploadTransport || '').trim().toLowerCase(),
usedBytes: Number(serverState.sizeBytes || 0),
lastBlockNumber: Number(serverState.lastNumber),
lastBlockHash: String(serverState.lastHash || '').trim().toLowerCase(),
uploadedAtMs: Date.now(),
});
setStatus('Слепок создан. Лучше подождать 1-2 минуты и затем обновить PDA в Solana.');
await refreshState();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось сделать слепок: ${error?.message || 'unknown'}`);
} finally {
if (modeToken === activeModeToken) {
setButtonsDisabled(false);
}
}
}
async function publishSnapshotToSolana() {
setButtonsDisabled(true);
try {
const localSnapshot = readStoredBlockchainSnapshot(login);
if (!localSnapshot?.txId) throw new Error('Сначала сделайте слепок в Arweave или Turbo.');
const [usage, serverState, signing] = await Promise.all([
getShineBlockchainUsage({
login,
solanaEndpoint: state.entrySettings.solanaServer,
}),
fetchServerBlockchainState(),
resolveWalletSigningMaterial(),
]);
if (String(localSnapshot.blockchainName || '') !== String(usage.blockchainName || serverState.blockchainName || '')) {
throw new Error('Локальный слепок относится к другому блокчейну.');
}
if (Number(localSnapshot.lastBlockNumber) !== Number(serverState.lastNumber)
|| normalizeHex64(localSnapshot.lastBlockHash) !== normalizeHex64(serverState.lastHash)
|| Number(localSnapshot.usedBytes || 0) !== Number(serverState.sizeBytes || 0)) {
throw new Error('Локальный слепок устарел. Сделайте новый слепок для текущей вершины.');
}
const result = await updateShineUserPdaOnSolana({
login,
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,
nextArweaveTxId: localSnapshot.txId,
});
if (modeToken !== activeModeToken) return;
saveStoredBlockchainSnapshot(login, {
...localSnapshot,
solanaUpdatedAtMs: Date.now(),
solanaSignature: String(result.signature || '').trim(),
});
setStatus(`Слепок закреплён в Solana. Tx: ${result.signature}`);
await refreshState();
} catch (error) {
if (modeToken !== activeModeToken) return;
setStatus(`Не удалось обновить Solana: ${error?.message || 'unknown'}`);
} finally {
if (modeToken === activeModeToken) {
setButtonsDisabled(false);
}
}
}
refreshBtn.addEventListener('click', () => {
void refreshState();
});
createBtn.addEventListener('click', () => {
void createSnapshot();
});
publishBtn.addEventListener('click', () => {
void publishSnapshotToSolana();
});
clearBtn.addEventListener('click', () => {
clearStoredBlockchainSnapshot(login);
setStatus('Локальный слепок удалён из браузера.');
void refreshState();
});
content.append(backBtn, summaryCard, publicationStatus, pdaCard, serverCard, localCard, actions);
setStatus('Загрузка состояния публикации...');
await refreshState();
}
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 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);
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;
}