1791 lines
76 KiB
JavaScript
1791 lines
76 KiB
JavaScript
import { renderHeader } from '../components/header.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 {
|
||
calcLimitTopupPriceLamports,
|
||
getLimitStepBytes,
|
||
getShineBlockchainUsage,
|
||
getShineUsersEconomyConfig,
|
||
updateShineUserPdaOnSolana,
|
||
} from '../services/shine-blockchain-wallet-service.js?v=202605300007';
|
||
|
||
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 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;
|
||
}
|
||
|
||
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 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: 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;
|
||
}
|
||
|
||
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', () => {
|
||
void renderSupportQueue();
|
||
});
|
||
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;">
|
||
Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате <code>2-15</code> или <code>3 8</code>.
|
||
Экран покажет, сколько билетов перед ним уже стоит, сколько уже выплачено и кому принадлежит билет.
|
||
</p>
|
||
`;
|
||
|
||
const inputLabel = document.createElement('label');
|
||
inputLabel.className = 'meta-muted';
|
||
inputLabel.setAttribute('for', 'support-ticket-query');
|
||
inputLabel.textContent = 'Номер билета';
|
||
|
||
const queryInput = document.createElement('input');
|
||
queryInput.id = 'support-ticket-query';
|
||
queryInput.type = 'text';
|
||
queryInput.placeholder = 'Например: 12, 2-5 или 3 8';
|
||
queryInput.autocomplete = 'off';
|
||
queryInput.spellcheck = false;
|
||
styleSupportInputField(queryInput);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'row';
|
||
actions.innerHTML = `
|
||
<button class="primary-btn" type="button" id="support-ticket-load" style="width:100%;">Показать билет</button>
|
||
<button class="ghost-btn" type="button" id="support-ticket-reset" style="width:100%;">Сбросить</button>
|
||
`;
|
||
|
||
const result = document.createElement('div');
|
||
result.className = 'card stack';
|
||
result.innerHTML = `
|
||
<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>
|
||
`;
|
||
|
||
let lastCoreState = null;
|
||
|
||
const renderTicketInfo = async () => {
|
||
const raw = String(queryInput.value || '').trim();
|
||
if (!raw) {
|
||
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета.</p>`;
|
||
return;
|
||
}
|
||
try {
|
||
const parsed = parseSupportTicketInput(raw);
|
||
const core = lastCoreState || await loadSupportPaymentsCore(state.entrySettings.solanaServer);
|
||
lastCoreState = core;
|
||
const solana = await loadSolanaWeb3();
|
||
const ticketPdaSeed = queueSeedFor(parsed.queueId);
|
||
const ticketIndexBytes = u64ToBytes(parsed.index);
|
||
const [ticketPda] = solana.PublicKey.findProgramAddressSync(
|
||
[utf8Bytes(ticketPdaSeed), ticketIndexBytes],
|
||
core.programId,
|
||
);
|
||
const ticketInfo = await core.connection.getAccountInfo(ticketPda, 'confirmed');
|
||
if (!ticketInfo) {
|
||
result.innerHTML = `
|
||
<p class="meta-muted" style="margin:0;">Билет ${formatQueuePrefix(parsed.queueId)}-${parsed.index.toString()} пока не создан.</p>
|
||
`;
|
||
return;
|
||
}
|
||
const ticket = parsePaymentsTicket(ticketInfo.data);
|
||
const queueState = queueStateView(core.queues, parsed.queueId);
|
||
const ticketsBefore = parsed.index > 0n ? parsed.index - 1n : 0n;
|
||
const paidBefore = queueState.ticketsPaid < ticketsBefore ? queueState.ticketsPaid : ticketsBefore;
|
||
const remainingBefore = ticketsBefore > paidBefore ? ticketsBefore - paidBefore : 0n;
|
||
result.innerHTML = `
|
||
<div><b>Билет:</b> ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()}</div>
|
||
<div><b>Статус:</b> ${ticket.isPaid ? 'выплачен' : 'ожидает выплаты'}</div>
|
||
<div><b>Получатель:</b> <span style="word-break:break-all;">${ticket.recipientWallet}</span></div>
|
||
<div><b>До него в очереди:</b> ${ticketsBefore.toString()} билетов</div>
|
||
<div><b>Из них уже выплачено:</b> ${paidBefore.toString()} билетов</div>
|
||
<div><b>Ещё осталось до него:</b> ${remainingBefore.toString()} билетов</div>
|
||
<div><b>Уже выплачено по сумме в очереди:</b> ${formatUsdCentsText(queueState.sumPaidUsdCents)} USD</div>
|
||
<div><b>Сумма этого билета:</b> ${formatUsdCentsText(ticket.payoutUsdCents)} USD</div>
|
||
<div><b>Накоплено до этого билета:</b> ${formatUsdCentsText(ticket.debtBeforeUsdCents)} USD в очереди до него</div>
|
||
`;
|
||
setStatus(`Билет ${formatQueuePrefix(parsed.queueId)}-${ticket.index.toString()} найден.`);
|
||
} catch (error) {
|
||
result.innerHTML = `<p class="meta-muted" style="margin:0;">${String(error?.message || 'Не удалось загрузить билет')}</p>`;
|
||
setStatus(`Не удалось посмотреть билет: ${error?.message || 'unknown'}`);
|
||
}
|
||
};
|
||
|
||
actions.querySelector('#support-ticket-load')?.addEventListener('click', () => {
|
||
void renderTicketInfo();
|
||
});
|
||
actions.querySelector('#support-ticket-reset')?.addEventListener('click', () => {
|
||
queryInput.value = '';
|
||
result.innerHTML = `<p class="meta-muted" style="margin:0;">Введите номер билета и нажмите кнопку.</p>`;
|
||
setStatus('Поле билета очищено.');
|
||
});
|
||
|
||
content.append(backBtn, card, inputLabel, queryInput, actions, result);
|
||
setStatus('Просмотр билета готов. Введите номер в нужном формате.');
|
||
}
|
||
|
||
async function 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 supportBtn = document.createElement('button');
|
||
supportBtn.className = 'primary-btn';
|
||
supportBtn.style.width = '100%';
|
||
supportBtn.textContent = 'Поддержать проект Сияние';
|
||
supportBtn.addEventListener('click', () => {
|
||
void renderSupportHub();
|
||
});
|
||
|
||
card.append(solanaBtn, arweaveBtn, shineBchBtn, supportBtn);
|
||
content.append(card);
|
||
setStatus('Выберите тип кошелька.');
|
||
}
|
||
|
||
async function renderShineBlockchainWallet() {
|
||
const modeToken = ++activeModeToken;
|
||
clearArweaveSecretsInMemory();
|
||
content.innerHTML = '';
|
||
|
||
const backBtn = createModeBackButton(renderWalletChoice);
|
||
const card = document.createElement('div');
|
||
card.className = 'card stack';
|
||
|
||
const limitLabel = document.createElement('p');
|
||
limitLabel.className = 'meta-muted';
|
||
limitLabel.textContent = 'Лимит блокчейна';
|
||
const limitValue = document.createElement('h2');
|
||
limitValue.style.fontSize = '26px';
|
||
limitValue.textContent = '— KB';
|
||
|
||
const usedLabel = document.createElement('p');
|
||
usedLabel.className = 'meta-muted';
|
||
usedLabel.textContent = 'Израсходовано (фактически на сервере)';
|
||
const usedValue = document.createElement('h2');
|
||
usedValue.style.fontSize = '26px';
|
||
usedValue.textContent = '— KB';
|
||
|
||
const leftLabel = document.createElement('p');
|
||
leftLabel.className = 'meta-muted';
|
||
leftLabel.textContent = 'Осталось';
|
||
const leftValue = document.createElement('h2');
|
||
leftValue.style.fontSize = '30px';
|
||
leftValue.textContent = '— KB';
|
||
|
||
const pdaLabel = document.createElement('p');
|
||
pdaLabel.className = 'meta-muted';
|
||
pdaLabel.style.wordBreak = 'break-all';
|
||
pdaLabel.textContent = 'PDA: —';
|
||
|
||
const endpointLabel = document.createElement('p');
|
||
endpointLabel.className = 'meta-muted';
|
||
endpointLabel.textContent = `RPC: ${state.entrySettings.solanaServer}`;
|
||
|
||
const updatedLabel = document.createElement('p');
|
||
updatedLabel.className = 'meta-muted';
|
||
updatedLabel.textContent = 'Обновлено: —';
|
||
|
||
const serverTitle = document.createElement('h3');
|
||
serverTitle.style.margin = '14px 0 0';
|
||
serverTitle.textContent = 'Фактическое состояние на сервере';
|
||
const serverSizeLabel = document.createElement('p');
|
||
serverSizeLabel.className = 'meta-muted';
|
||
serverSizeLabel.textContent = 'Размер цепочки: —';
|
||
const serverLastLabel = document.createElement('p');
|
||
serverLastLabel.className = 'meta-muted';
|
||
serverLastLabel.textContent = 'Крайний блок: —';
|
||
const serverLastHashLabel = document.createElement('p');
|
||
serverLastHashLabel.className = 'meta-muted';
|
||
serverLastHashLabel.style.wordBreak = 'break-all';
|
||
serverLastHashLabel.style.fontSize = '11px';
|
||
serverLastHashLabel.textContent = 'Hash: —';
|
||
|
||
const solanaTitle = document.createElement('h3');
|
||
solanaTitle.style.margin = '14px 0 0';
|
||
solanaTitle.textContent = 'Закреплено в Solana';
|
||
const solanaLastLabel = document.createElement('p');
|
||
solanaLastLabel.className = 'meta-muted';
|
||
solanaLastLabel.textContent = 'Крайний блок: —';
|
||
const solanaLastHashLabel = document.createElement('p');
|
||
solanaLastHashLabel.className = 'meta-muted';
|
||
solanaLastHashLabel.style.wordBreak = 'break-all';
|
||
solanaLastHashLabel.style.fontSize = '11px';
|
||
solanaLastHashLabel.textContent = 'Hash: —';
|
||
|
||
card.append(
|
||
limitLabel,
|
||
limitValue,
|
||
usedLabel,
|
||
usedValue,
|
||
leftLabel,
|
||
leftValue,
|
||
pdaLabel,
|
||
endpointLabel,
|
||
updatedLabel,
|
||
serverTitle,
|
||
serverSizeLabel,
|
||
serverLastLabel,
|
||
serverLastHashLabel,
|
||
solanaTitle,
|
||
solanaLastLabel,
|
||
solanaLastHashLabel,
|
||
);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'stack';
|
||
actions.innerHTML = `
|
||
<button class="ghost-btn" id="refresh-shine-bch" style="width:100%;">Обновить</button>
|
||
<button class="primary-btn" id="sync-shine-solana" style="width:100%;">Закрепить в Solana</button>
|
||
<button class="primary-btn" id="topup-shine-limit" style="width:100%;">Увеличить лимит</button>
|
||
`;
|
||
const refreshBtn = actions.querySelector('#refresh-shine-bch');
|
||
const syncBtn = actions.querySelector('#sync-shine-solana');
|
||
const topupBtn = actions.querySelector('#topup-shine-limit');
|
||
|
||
const fetchServerState = async () => {
|
||
const user = await authService.getUser(String(state.session.login || '').trim());
|
||
if (!user?.exists) throw new Error('Пользователь не найден на сервере');
|
||
const lastNumber = Number(user.serverLastGlobalNumber ?? -1);
|
||
return {
|
||
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
|
||
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
|
||
lastNumber,
|
||
lastHash: String(user.serverLastGlobalHash || ''),
|
||
};
|
||
};
|
||
|
||
const resolveWalletSigningMaterial = async () => {
|
||
const { login, storagePwd } = sessionArgsOrThrow();
|
||
let saved;
|
||
try {
|
||
saved = await loadEncryptedUserSecrets(login, storagePwd);
|
||
} catch {
|
||
saved = null;
|
||
}
|
||
let rootKey = String(saved?.rootKey || '').trim();
|
||
let blockchainKey = String(saved?.blockchainKey || '').trim();
|
||
const clientKey = String(saved?.clientKey || '').trim();
|
||
if (!clientKey) throw new Error('На устройстве нет client.key. Выполните вход заново.');
|
||
if (rootKey && blockchainKey) {
|
||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
||
}
|
||
|
||
const password = window.prompt(
|
||
'Для операции нужен root key (и blockchain key), но они не сохранены на устройстве.\nВведите пароль аккаунта для временного восстановления ключей:',
|
||
'',
|
||
);
|
||
if (password == null) throw new Error('Операция отменена пользователем');
|
||
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
|
||
rootKey = keyBundle?.rootPair?.privatePkcs8B64 || '';
|
||
blockchainKey = keyBundle?.blockchainPair?.privatePkcs8B64 || '';
|
||
if (!rootKey || !blockchainKey) throw new Error('Не удалось восстановить root/blockchain key из пароля');
|
||
|
||
const shouldSave = window.confirm(
|
||
'Сохранить root key и blockchain key в зашифрованном контейнере этого устройства?\nВнимание: хранить ключи на телефоне менее безопасно.',
|
||
);
|
||
if (shouldSave) {
|
||
await authService.persistSelectedKeys(login, storagePwd, keyBundle, { saveRoot: true, saveBlockchain: true });
|
||
}
|
||
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, clientPrivatePkcs8B64: clientKey };
|
||
};
|
||
|
||
const setButtonsDisabled = (disabled) => {
|
||
refreshBtn.disabled = disabled;
|
||
syncBtn.disabled = disabled;
|
||
topupBtn.disabled = disabled;
|
||
};
|
||
|
||
const refreshUsage = async () => {
|
||
setButtonsDisabled(true);
|
||
try {
|
||
const [usage, serverState] = await Promise.all([
|
||
getShineBlockchainUsage({
|
||
login: String(state.session.login || '').trim(),
|
||
solanaEndpoint: state.entrySettings.solanaServer,
|
||
}),
|
||
fetchServerState(),
|
||
]);
|
||
if (modeToken !== activeModeToken) return;
|
||
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
|
||
const usedServerBytes = BigInt(Math.max(0, Number(serverState.sizeBytes || 0)));
|
||
const leftServerBytes = usage.paidLimitBytes > usedServerBytes ? (usage.paidLimitBytes - usedServerBytes) : 0n;
|
||
usedValue.textContent = formatKbFromBytes(usedServerBytes);
|
||
leftValue.textContent = formatKbFromBytes(leftServerBytes);
|
||
pdaLabel.textContent = `PDA: ${usage.userPda}`;
|
||
endpointLabel.textContent = `RPC: ${usage.endpoint}`;
|
||
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
|
||
|
||
serverSizeLabel.textContent = `Размер цепочки: ${formatKbFromBytes(serverState.sizeBytes)}`;
|
||
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
|
||
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
|
||
|
||
solanaLastLabel.textContent = `Крайний блок: ${usage.lastBlockNumber}`;
|
||
solanaLastHashLabel.textContent = `Hash: ${usage.lastBlockHashHex || '—'}`;
|
||
|
||
setStatus('Данные лимита и состояния блокчейна обновлены.');
|
||
} catch (error) {
|
||
if (modeToken !== activeModeToken) return;
|
||
setStatus(`Не удалось обновить состояние блокчейна: ${error?.message || 'unknown'}`);
|
||
} finally {
|
||
setButtonsDisabled(false);
|
||
}
|
||
};
|
||
|
||
syncBtn.addEventListener('click', async () => {
|
||
setButtonsDisabled(true);
|
||
try {
|
||
const [serverState, signing] = await Promise.all([
|
||
fetchServerState(),
|
||
resolveWalletSigningMaterial(),
|
||
]);
|
||
const result = await updateShineUserPdaOnSolana({
|
||
login: String(state.session.login || '').trim(),
|
||
solanaEndpoint: state.entrySettings.solanaServer,
|
||
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
|
||
additionalLimitBytes: 0n,
|
||
nextUsedBytes: BigInt(Math.max(0, serverState.sizeBytes)),
|
||
nextLastBlockNumber: serverState.lastNumber,
|
||
nextLastBlockHashHex: serverState.lastHash,
|
||
});
|
||
if (modeToken !== activeModeToken) return;
|
||
setStatus(`Состояние закреплено в Solana. Tx: ${result.signature}`);
|
||
await refreshUsage();
|
||
} catch (error) {
|
||
if (modeToken !== activeModeToken) return;
|
||
setStatus(`Не удалось закрепить состояние в Solana: ${error?.message || 'unknown'}`);
|
||
} finally {
|
||
setButtonsDisabled(false);
|
||
}
|
||
});
|
||
|
||
topupBtn.addEventListener('click', async () => {
|
||
setButtonsDisabled(true);
|
||
try {
|
||
const economy = await getShineUsersEconomyConfig({ solanaEndpoint: state.entrySettings.solanaServer });
|
||
const step = getLimitStepBytes();
|
||
const input = window.prompt(
|
||
`Введите, на сколько увеличить лимит (в байтах, шаг ${step.toString()}).\nЦена за шаг: ${lamportsToSolText(economy.lamportsPerLimitStep)} SOL`,
|
||
step.toString(),
|
||
);
|
||
if (!input) {
|
||
setStatus('Увеличение лимита отменено.');
|
||
return;
|
||
}
|
||
const addBytes = BigInt(String(input).trim());
|
||
const priceLamports = calcLimitTopupPriceLamports(addBytes, economy.lamportsPerLimitStep);
|
||
const confirm = window.confirm(
|
||
`Будет увеличено на ${formatKbFromBytes(addBytes)}.\n` +
|
||
`С вашего Solana-счёта будет списано ~${lamportsToSolText(priceLamports)} SOL.\n` +
|
||
`Продолжить?`,
|
||
);
|
||
if (!confirm) {
|
||
setStatus('Увеличение лимита отменено.');
|
||
return;
|
||
}
|
||
const signing = await resolveWalletSigningMaterial();
|
||
const result = await updateShineUserPdaOnSolana({
|
||
login: String(state.session.login || '').trim(),
|
||
solanaEndpoint: state.entrySettings.solanaServer,
|
||
rootPrivatePkcs8B64: signing.rootPrivatePkcs8B64,
|
||
blockchainPrivatePkcs8B64: signing.blockchainPrivatePkcs8B64,
|
||
clientPrivatePkcs8B64: signing.clientPrivatePkcs8B64,
|
||
additionalLimitBytes: addBytes,
|
||
});
|
||
if (modeToken !== activeModeToken) return;
|
||
setStatus(`Лимит увеличен. Tx: ${result.signature}`);
|
||
await refreshUsage();
|
||
} catch (error) {
|
||
if (modeToken !== activeModeToken) return;
|
||
setStatus(`Не удалось увеличить лимит: ${error?.message || 'unknown'}`);
|
||
} finally {
|
||
setButtonsDisabled(false);
|
||
}
|
||
});
|
||
|
||
refreshBtn.addEventListener('click', () => {
|
||
void refreshUsage();
|
||
});
|
||
|
||
content.append(backBtn, card, actions);
|
||
setStatus('Загрузка данных блокчейна Сияния...');
|
||
await refreshUsage();
|
||
}
|
||
|
||
async function renderSolanaWallet() {
|
||
const modeToken = ++activeModeToken;
|
||
clearArweaveSecretsInMemory();
|
||
content.innerHTML = '';
|
||
|
||
let walletCtx = null;
|
||
let walletAddress = '';
|
||
|
||
const backBtn = createModeBackButton(renderWalletChoice);
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'card stack';
|
||
|
||
const balanceWrap = document.createElement('div');
|
||
const balanceLabel = document.createElement('p');
|
||
balanceLabel.className = 'meta-muted';
|
||
balanceLabel.textContent = 'Баланс (Solana)';
|
||
const balanceValue = document.createElement('h2');
|
||
balanceValue.style.fontSize = '30px';
|
||
balanceValue.textContent = '— SOL';
|
||
const updatedLabel = document.createElement('p');
|
||
updatedLabel.className = 'meta-muted';
|
||
updatedLabel.textContent = 'Обновлено: —';
|
||
const endpointLabel = document.createElement('p');
|
||
endpointLabel.className = 'meta-muted';
|
||
endpointLabel.textContent = `RPC: ${state.entrySettings.solanaServer}`;
|
||
balanceWrap.append(balanceLabel, balanceValue, updatedLabel, endpointLabel);
|
||
|
||
const addressCard = document.createElement('div');
|
||
addressCard.className = 'card';
|
||
addressCard.style.padding = '10px';
|
||
addressCard.innerHTML = `
|
||
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (client.key)</p>
|
||
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
|
||
`;
|
||
const addressEl = addressCard.querySelector('#wallet-address-value');
|
||
|
||
card.append(balanceWrap, addressCard);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'stack';
|
||
actions.innerHTML = `
|
||
<div class="row">
|
||
<button class="text-btn" id="copy-address">Копировать адрес</button>
|
||
<button class="ghost-btn" id="refresh-balance">Обновить баланс</button>
|
||
</div>
|
||
<div class="row">
|
||
<button class="primary-btn" id="send-sol" style="width:100%;">Перевести</button>
|
||
<button class="primary-btn" id="topup-sol" style="width:100%;">Пополнить</button>
|
||
</div>
|
||
`;
|
||
|
||
const copyBtn = actions.querySelector('#copy-address');
|
||
const refreshBtn = actions.querySelector('#refresh-balance');
|
||
const sendBtn = actions.querySelector('#send-sol');
|
||
const topupBtn = actions.querySelector('#topup-sol');
|
||
|
||
const 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;
|
||
}
|