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 = `
Поддержать проект Сияние
Здесь можно купить билет, посмотреть очередь и увидеть, как устроена покупка.
Оплата идет в SOL, а сумма считается по курсу USD/USDT на момент транзакции.
Номер билета и секретный ключ кошелька нужно сохранить отдельно.
Генерация использует безопасный рандом браузера, а ваш дополнительный текст и текущее время добавляются как примесь.
Вводить текст необязательно: даже без него ключи остаются случайными.
Для Q1 вводите просто номер билета. Для Q2 и Q3 укажите номер в формате 2-15 или 3 8.
Экран покажет, сколько билетов перед ним уже стоит, сколько уже выплачено и кому принадлежит билет.
Сначала показываем условия и текущий лимит. После этого можно перейти к покупке и ввести сумму в долларах.
Оплата идет в SOL, а расчет строится по курсу USD/USDT на момент транзакции.
Уже куплено в очереди 1: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
Осталось купить по текущему коэффициенту: ${formatUsdCentsText(remainingUsdCents)} USD
Сколько билетов уже в очереди: ${queue.ticketsTotal.toString()}
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
Курс: 1 SOL = ${formatPythSolUsdText(currentCore.pyth)} USD
После заполнения лимита будет новый лимит, но уже с более низким коэффициентом.
`;
nextBtn.disabled = false;
setStatus('Условия покупки загружены. Можно переходить к форме покупки.');
} catch (error) {
if (modeToken !== activeModeToken) return;
stateCard.innerHTML = `
${String(error?.message || 'Не удалось загрузить состояние')}
Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата.
Покупка подписывается вашим client key и отклоняется, если курс уходит дальше допустимого порога.
В очереди перед вами уже куплено: ${formatUsdCentsText(queue.sumTotalUsdCents)} USD
Осталось до лимита текущего коэффициента: ${formatUsdCentsText(remainingUsdCents)} USD
Следующий билет: №${(queue.ticketsTotal + 1n).toString()}
Примерная цена: ${amountSol} SOL
Максимум при допуске 3%: ${maxPaySol} SOL
${canBuy ? 'Покупка укладывается в текущий лимит.' : 'Сумма сейчас не укладывается в текущий лимит или введена некорректно.'}
Если текущий лимит заполнится, откроется новый лимит с более низким коэффициентом.
Покупка идет в SOL, но считается по курсу USD/USDT на момент транзакции.
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.