Добавить кошелек блокчейна и озвучивание агента

This commit is contained in:
AidarKC
2026-05-29 23:48:44 +04:00
parent 775b655aac
commit 3a5856c7f0
19 changed files with 1301 additions and 28 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ import * as keyStorageView from './pages/key-storage-view.js';
import * as profileView from './pages/profile-view.js';
import * as profileEditView from './pages/profile-edit-view.js';
import * as walletView from './pages/wallet-view.js';
import * as walletView from './pages/wallet-view.js?v=2026052806';
import * as settingsView from './pages/settings-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js';
+10 -3
View File
@@ -1,7 +1,11 @@
import { renderHeader } from '../components/header.js';
import { authService, clearAuthMessages, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { formatSolanaErrorDetails, precheckLoginClassOnSolana } from '../services/solana-register-service.js';
import {
checkLoginExistsOnSolana,
formatSolanaErrorDetails,
precheckLoginClassOnSolana,
} from '../services/solana-register-service.js';
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
@@ -87,8 +91,11 @@ export function render({ navigate }) {
checkButton.disabled = true;
checkButton.textContent = 'Проверка...';
try {
await authService.reconnect(state.entrySettings.shineServer);
const isFree = await authService.ensureLoginFree(login);
const check = await checkLoginExistsOnSolana({
login,
solanaEndpoint: state.entrySettings.solanaServer,
});
const isFree = !check.exists;
let className = '';
let precheckWarning = '';
if (isFree) {
+312 -2
View File
@@ -1,5 +1,5 @@
import { renderHeader } from '../components/header.js';
import { state } from '../state.js';
import { authService, state } from '../state.js';
import {
createRandomSolanaWallet,
createSolanaWalletFromPrivateBase58,
@@ -17,6 +17,14 @@ import {
getArweaveWalletFromStoredDeviceKey,
transferAr,
} from '../services/arweave-wallet-service.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import {
calcLimitTopupPriceLamports,
getLimitStepBytes,
getShineBlockchainUsage,
getShineUsersEconomyConfig,
updateShineUserPdaOnSolana,
} from '../services/shine-blockchain-wallet-service.js?v=2026052803';
export const pageMeta = { id: 'wallet-view', title: 'Кошелёк' };
const SOLANA_PRIVATE_BASE58_MAX_LEN = 44;
@@ -25,6 +33,20 @@ 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';
@@ -106,11 +128,299 @@ export function render({ navigate }) {
void renderArweaveWallet();
});
card.append(solanaBtn, arweaveBtn);
const shineBchBtn = document.createElement('button');
shineBchBtn.className = 'primary-btn';
shineBchBtn.style.width = '100%';
shineBchBtn.textContent = 'Блокчейн Сияния';
shineBchBtn.addEventListener('click', () => {
void renderShineBlockchainWallet();
});
card.append(solanaBtn, arweaveBtn, shineBchBtn);
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 = 'Израсходовано (закреплено в Solana)';
const usedValue = document.createElement('h2');
usedValue.style.fontSize = '26px';
usedValue.textContent = '— KB';
const leftLabel = document.createElement('p');
leftLabel.className = 'meta-muted';
leftLabel.textContent = 'Осталось (по Solana)';
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 = '8px 0 0';
serverTitle.textContent = 'Фактическое состояние на сервере';
const serverBlocksLabel = document.createElement('p');
serverBlocksLabel.className = 'meta-muted';
serverBlocksLabel.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.textContent = 'Hash: —';
const solanaTitle = document.createElement('h3');
solanaTitle.style.margin = '8px 0 0';
solanaTitle.textContent = 'Закреплено в Solana';
const solanaBlocksLabel = document.createElement('p');
solanaBlocksLabel.className = 'meta-muted';
solanaBlocksLabel.textContent = 'Блоков: —';
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.textContent = 'Hash: —';
card.append(
limitLabel,
limitValue,
usedLabel,
usedValue,
leftLabel,
leftValue,
pdaLabel,
endpointLabel,
updatedLabel,
serverTitle,
serverBlocksLabel,
serverSizeLabel,
serverLastLabel,
serverLastHashLabel,
solanaTitle,
solanaBlocksLabel,
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('Пользователь не найден на сервере');
return {
blocksCount: Number(user.serverBlocksCount || 0),
sizeBytes: Number(user.serverBlockchainSizeBytes || 0),
sizeLimitBytes: Number(user.serverBlockchainSizeLimitBytes || 0),
lastNumber: Number(user.serverLastGlobalNumber ?? -1),
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 deviceKey = String(saved?.deviceKey || '').trim();
if (!deviceKey) throw new Error('На устройстве нет device.key. Выполните вход заново.');
if (rootKey && blockchainKey) {
return { rootPrivatePkcs8B64: rootKey, blockchainPrivatePkcs8B64: blockchainKey, devicePrivatePkcs8B64: deviceKey };
}
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, devicePrivatePkcs8B64: deviceKey };
};
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;
const solanaBlocks = usage.lastBlockNumber >= 0 ? usage.lastBlockNumber + 1 : 0;
const serverBlocks = serverState.blocksCount;
limitValue.textContent = formatKbFromBytes(usage.paidLimitBytes);
usedValue.textContent = formatKbFromBytes(usage.usedBytes);
leftValue.textContent = formatKbFromBytes(usage.leftBytes);
pdaLabel.textContent = `PDA: ${usage.userPda}`;
endpointLabel.textContent = `RPC: ${usage.endpoint}`;
updatedLabel.textContent = `Обновлено: ${nowRu()}`;
serverBlocksLabel.textContent = `Блоков: ${serverBlocks.toLocaleString('ru-RU')}`;
serverSizeLabel.textContent = `Размер цепочки: ${formatKbFromBytes(serverState.sizeBytes)} из ${formatKbFromBytes(serverState.sizeLimitBytes)}`;
serverLastLabel.textContent = `Крайний блок: ${serverState.lastNumber}`;
serverLastHashLabel.textContent = `Hash: ${serverState.lastHash || '—'}`;
solanaBlocksLabel.textContent = `Блоков: ${solanaBlocks.toLocaleString('ru-RU')}`;
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,
devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64,
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,
devicePrivatePkcs8B64: signing.devicePrivatePkcs8B64,
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();
+14 -4
View File
@@ -852,16 +852,26 @@ export class AuthService {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Введите логин');
const user = await this.getUser(cleanLogin);
if (!user.exists) throw new Error('Пользователь не найден');
const keyBundle = await this.derivePasswordKeyBundle(cleanLogin, password);
const session = await this.createAuthSession(cleanLogin, keyBundle);
return { ...session, keyBundle };
}
async persistSelectedKeys(login, storagePwd, keyBundle, saveOptions = { saveRoot: true, saveBlockchain: true }) {
const secrets = { deviceKey: keyBundle.devicePair.privatePkcs8B64 };
let currentSecrets = {};
try {
const loaded = await loadEncryptedUserSecrets(login, storagePwd);
if (loaded && typeof loaded === 'object') {
currentSecrets = loaded;
}
} catch {
// Если контейнера ещё нет или пароль новый для этого логина — создадим новый ниже.
}
const secrets = {
...currentSecrets,
deviceKey: keyBundle.devicePair.privatePkcs8B64,
};
if (saveOptions.saveRoot) secrets.rootKey = keyBundle.rootPair.privatePkcs8B64;
if (saveOptions.saveBlockchain) secrets.blockchainKey = keyBundle.blockchainPair.privatePkcs8B64;
await saveEncryptedUserSecrets(login, storagePwd, secrets);
@@ -0,0 +1,489 @@
import { importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
import { SHINE_PAYMENTS_PROGRAM_ID, SHINE_USERS_ECONOMY_CONFIG_SEED, SHINE_USERS_PROGRAM_ID } from '../solana-programs.js';
const MAGIC = 'SHiNE';
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
const LIMIT_STEP = 10_000n;
const UPDATE_USER_PDA_DISCRIMINATOR = new Uint8Array([42, 133, 114, 232, 38, 245, 167, 234]);
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
const BLOCK_TYPE_ROOT_KEY = 1;
const BLOCK_TYPE_DEVICE_KEY = 2;
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
const BLOCK_TYPE_SERVER_PROFILE = 30;
const BLOCK_TYPE_ACCESS_SERVERS = 40;
const BLOCK_TYPE_TRUSTED_STATE = 50;
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
return solanaLibPromise;
}
function pushU32LE(buf, v) {
const n = Number(v) >>> 0;
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
}
function pushU64LE(buf, v) {
const b = BigInt(v);
const lo = Number(b & 0xffffffffn) >>> 0;
const hi = Number((b >> 32n) & 0xffffffffn) >>> 0;
pushU32LE(buf, lo);
pushU32LE(buf, hi);
}
function pushStrU8(buf, value) {
const bytes = new TextEncoder().encode(String(value || ''));
if (bytes.length > 255) throw new Error('Слишком длинная строка для формата U8');
buf.push(bytes.length);
for (const x of bytes) buf.push(x);
}
function pushStrU32(buf, value) {
const bytes = new TextEncoder().encode(String(value || ''));
pushU32LE(buf, bytes.length);
for (const x of bytes) buf.push(x);
}
function pushVecU8(buf, bytes) {
const data = bytes || new Uint8Array();
pushU32LE(buf, data.length);
for (const x of data) buf.push(x);
}
function pushVecStrU32(buf, values) {
const arr = Array.isArray(values) ? values : [];
pushU32LE(buf, arr.length);
for (const s of arr) pushStrU32(buf, s);
}
function makeReader(bytes) {
let o = 0;
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ensure = (n) => { if (o + n > bytes.length) throw new Error('Повреждённый формат PDA'); };
const readU8 = () => { ensure(1); const v = dv.getUint8(o); o += 1; return v; };
const readU16 = () => { ensure(2); const v = dv.getUint16(o, true); o += 2; return v; };
const readU32 = () => { ensure(4); const v = dv.getUint32(o, true); o += 4; return v; };
const readU64 = () => { ensure(8); const v = dv.getBigUint64(o, true); o += 8; return v; };
const readBytes = (n) => { ensure(n); const out = bytes.slice(o, o + n); o += n; return out; };
const readStrU8 = () => {
const len = readU8();
return new TextDecoder().decode(readBytes(len));
};
return { readU8, readU16, readU32, readU64, readBytes, readStrU8 };
}
function parseShineUserPda(dataBytes) {
const r = makeReader(dataBytes);
const magic = new TextDecoder().decode(r.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
r.readU8();
r.readU8();
r.readU16();
const createdAtMs = r.readU64();
const updatedAtMs = r.readU64();
const recordNumber = r.readU32();
const prevRecordHash = r.readBytes(32);
const login = r.readStrU8();
const blocksCount = r.readU8();
const out = {
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
rootKey: null,
deviceKey: null,
blockchain: null,
isServer: false,
serverKey: new Uint8Array(32),
serverAddress: '',
syncServers: [],
accessServers: [],
trustedCount: 0,
};
for (let i = 0; i < blocksCount; i += 1) {
const type = r.readU8();
r.readU8();
if (type === BLOCK_TYPE_ROOT_KEY) { out.rootKey = r.readBytes(32); continue; }
if (type === BLOCK_TYPE_DEVICE_KEY) { out.deviceKey = r.readBytes(32); continue; }
if (type === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = r.readU8();
for (let j = 0; j < count; j += 1) {
const blockchainType = r.readU8();
const blockchainName = r.readStrU8();
const blockchainPublicKey = r.readBytes(32);
const paidLimitBytes = r.readU64();
const usedBytes = r.readU64();
const lastBlockNumber = r.readU32();
const lastBlockHash = r.readBytes(32);
const lastBlockSignature = r.readBytes(64);
const arPresent = r.readU8();
const arweaveTxId = arPresent ? r.readStrU8() : '';
if (!out.blockchain) {
out.blockchain = {
blockchainType,
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
};
}
}
continue;
}
if (type === BLOCK_TYPE_SERVER_PROFILE) {
out.isServer = r.readU8() === 1;
out.serverKey = r.readBytes(32);
out.serverAddress = r.readStrU8();
const syncCount = r.readU8();
out.syncServers = [];
for (let k = 0; k < syncCount; k += 1) out.syncServers.push(r.readStrU8());
continue;
}
if (type === BLOCK_TYPE_ACCESS_SERVERS) {
const accessCount = r.readU8();
out.accessServers = [];
for (let k = 0; k < accessCount; k += 1) out.accessServers.push(r.readStrU8());
continue;
}
if (type === BLOCK_TYPE_TRUSTED_STATE) {
out.trustedCount = r.readU8();
continue;
}
throw new Error(`Неизвестный блок PDA: ${type}`);
}
if (!out.rootKey || !out.deviceKey || !out.blockchain) {
throw new Error('В PDA отсутствуют обязательные блоки');
}
return out;
}
function serializeUnsignedRecordFromState(stateLike) {
const buf = [];
const login = String(stateLike.login || '');
const bch = stateLike.blockchain;
buf.push(0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0);
pushU64LE(buf, stateLike.createdAtMs);
pushU64LE(buf, stateLike.updatedAtMs);
pushU32LE(buf, stateLike.recordNumber);
for (const x of stateLike.prevRecordHash) buf.push(x);
pushStrU8(buf, login);
const blocksCount = stateLike.isServer ? 6 : 5;
buf.push(blocksCount);
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
for (const x of stateLike.rootKey) buf.push(x);
buf.push(BLOCK_TYPE_DEVICE_KEY, 0);
for (const x of stateLike.deviceKey) buf.push(x);
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1);
buf.push(bch.blockchainType);
pushStrU8(buf, bch.blockchainName);
for (const x of bch.blockchainPublicKey) buf.push(x);
pushU64LE(buf, bch.paidLimitBytes);
pushU64LE(buf, bch.usedBytes);
pushU32LE(buf, bch.lastBlockNumber);
for (const x of bch.lastBlockHash) buf.push(x);
for (const x of bch.lastBlockSignature) buf.push(x);
if (String(bch.arweaveTxId || '').trim()) {
buf.push(1);
pushStrU8(buf, bch.arweaveTxId);
} else {
buf.push(0);
}
if (stateLike.isServer) {
buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1);
for (const x of stateLike.serverKey) buf.push(x);
pushStrU8(buf, stateLike.serverAddress);
const sync = Array.isArray(stateLike.syncServers) ? stateLike.syncServers : [];
buf.push(sync.length & 0xff);
for (const s of sync) pushStrU8(buf, s);
}
buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0);
const access = Array.isArray(stateLike.accessServers) ? stateLike.accessServers : [];
buf.push(access.length & 0xff);
for (const s of access) pushStrU8(buf, s);
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, Number(stateLike.trustedCount || 0) & 0xff);
const recLen = buf.length + 64;
buf[7] = recLen & 0xff;
buf[8] = (recLen >>> 8) & 0xff;
return new Uint8Array(buf);
}
function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) {
const buf = [];
for (const x of new TextEncoder().encode(LAST_BLOCK_STATE_PREFIX)) buf.push(x);
pushStrU8(buf, login);
pushStrU8(buf, blockchainName);
pushU32LE(buf, lastBlockNumber);
for (const x of lastBlockHash32) buf.push(x);
pushU64LE(buf, usedBytes);
return new Uint8Array(buf);
}
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
const sigOff = 16;
const pkOff = sigOff + 64;
const msgOff = pkOff + 32;
const data = new Uint8Array(msgOff + 32);
const v = new DataView(data.buffer);
data[0] = 1;
data[1] = 0;
v.setUint16(2, sigOff, true);
v.setUint16(4, 0xffff, true);
v.setUint16(6, pkOff, true);
v.setUint16(8, 0xffff, true);
v.setUint16(10, msgOff, true);
v.setUint16(12, 32, true);
v.setUint16(14, 0xffff, true);
data.set(sig64, sigOff);
data.set(pubkey32, pkOff);
data.set(msgHash32, msgOff);
return data;
}
function serializeUpdateUserPdaArgs(args) {
const b = [];
for (const x of UPDATE_USER_PDA_DISCRIMINATOR) b.push(x);
pushStrU32(b, args.login);
for (const x of args.rootKey32) b.push(x);
pushU64LE(b, args.createdAtMs);
pushU64LE(b, args.updatedAtMs);
pushU32LE(b, args.version);
pushVecU8(b, args.prevHash32);
pushU64LE(b, args.additionalLimitBytes);
for (const x of args.deviceKey32) b.push(x);
for (const x of args.blockchainPublicKey32) b.push(x);
pushStrU32(b, args.blockchainName);
pushU64LE(b, args.usedBytes);
pushU32LE(b, args.lastBlockNumber);
pushVecU8(b, args.lastBlockHash32);
pushVecU8(b, args.lastBlockSignature64);
pushStrU32(b, args.arweaveTxId);
b.push(args.isServer ? 1 : 0);
for (const x of args.serverKey32) b.push(x);
pushStrU32(b, args.serverAddress);
pushVecStrU32(b, args.syncServers);
pushVecStrU32(b, args.accessServers);
b.push(Number(args.trustedCount || 0) & 0xff);
pushVecU8(b, args.rootSignature64);
return new Uint8Array(b);
}
function parseUsersEconomyConfig(dataBytes) {
const v = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength);
if (dataBytes.byteLength < 25) throw new Error('Некорректный economy config');
return {
version: v.getUint8(0),
registrationFeeLamports: v.getBigUint64(1, true),
lamportsPerLimitStep: v.getBigUint64(9, true),
startBonusLimit: v.getBigUint64(17, true),
};
}
export async function getShineUsersEconomyConfig({ solanaEndpoint }) {
const endpoint = String(solanaEndpoint || '').trim();
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [economyPda] = solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)],
usersProgram,
);
const ai = await connection.getAccountInfo(economyPda, 'confirmed');
if (!ai?.data) throw new Error('Economy config PDA не найден');
const economy = parseUsersEconomyConfig(ai.data);
return { endpoint, economyPda: economyPda.toBase58(), ...economy };
}
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
const cleanLogin = String(login || '').trim().toLowerCase();
const endpoint = String(solanaEndpoint || '').trim();
if (!cleanLogin) throw new Error('Не указан логин');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const ai = await connection.getAccountInfo(userPda, 'confirmed');
if (!ai?.data) throw new Error('Пользовательский PDA не найден в Solana');
const parsed = parseShineUserPda(ai.data);
const bch = parsed.blockchain;
const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n;
return {
endpoint,
userPda: userPda.toBase58(),
login: parsed.login,
recordNumber: parsed.recordNumber,
paidLimitBytes: bch.paidLimitBytes,
usedBytes: bch.usedBytes,
leftBytes,
lastBlockNumber: bch.lastBlockNumber,
lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
}
export async function updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64,
devicePrivatePkcs8B64,
blockchainPrivatePkcs8B64,
additionalLimitBytes = 0n,
nextUsedBytes,
nextLastBlockNumber,
nextLastBlockHashHex,
}) {
const cleanLogin = String(login || '').trim().toLowerCase();
if (!cleanLogin) throw new Error('Не указан логин');
const endpoint = String(solanaEndpoint || '').trim();
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID);
const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID);
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram);
const userAi = await connection.getAccountInfo(userPda, 'confirmed');
if (!userAi?.data) throw new Error('PDA пользователя не найден');
const current = parseShineUserPda(userAi.data);
const currentBch = current.blockchain;
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
const effectiveLastHash = nextLastBlockHashHex
? Uint8Array.from(String(nextLastBlockHashHex).match(/.{1,2}/g).map((h) => parseInt(h, 16)))
: currentBch.lastBlockHash;
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
const addLimit = BigInt(additionalLimitBytes || 0);
if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит');
if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64);
const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
const updatedAtMs = BigInt(Date.now());
const newPaid = currentBch.paidLimitBytes + addLimit;
const newRecordNumber = current.recordNumber + 1;
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, currentBch.blockchainName, effectiveLastNum, effectiveLastHash, effectiveUsed);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash);
const nextState = {
...current,
updatedAtMs,
recordNumber: newRecordNumber,
prevRecordHash: prevHash,
blockchain: {
...currentBch,
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash: effectiveLastHash,
lastBlockSignature: lastBlockSig64,
},
};
const unsignedNext = serializeUnsignedRecordFromState(nextState);
const unsignedNextHash = await sha256Bytes(unsignedNext);
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
const ixData = serializeUpdateUserPdaArgs({
login: cleanLogin,
rootKey32: current.rootKey,
createdAtMs: current.createdAtMs,
updatedAtMs,
version: newRecordNumber,
prevHash32: prevHash,
additionalLimitBytes: addLimit,
deviceKey32: current.deviceKey,
blockchainPublicKey32: currentBch.blockchainPublicKey,
blockchainName: currentBch.blockchainName,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash32: effectiveLastHash,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
isServer: current.isServer,
serverKey32: current.serverKey,
serverAddress: current.serverAddress,
syncServers: current.syncServers,
accessServers: current.accessServers,
trustedCount: current.trustedCount,
rootSignature64: rootSig64,
});
const edIxRoot = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(rootSig64, current.rootKey, unsignedNextHash),
});
const edIxBch = new solana.TransactionInstruction({
programId: ed25519Program,
keys: [],
data: buildEd25519IxData(lastBlockSig64, currentBch.blockchainPublicKey, lastBlockStateHash),
});
const updIx = new solana.TransactionInstruction({
programId: usersProgram,
keys: [
{ pubkey: deviceKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: userPda, isSigner: false, isWritable: true },
{ pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: inflowVault, isSigner: false, isWritable: true },
{ pubkey: sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: economyPda, isSigner: false, isWritable: false },
],
data: ixData,
});
const signature = await solana.sendAndConfirmTransaction(
connection,
new solana.Transaction().add(edIxRoot, edIxBch, updIx),
[deviceKeypair],
{ commitment: 'confirmed' },
);
return {
signature,
userPda: userPda.toBase58(),
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n,
lastBlockNumber: effectiveLastNum,
lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''),
};
}
export function calcLimitTopupPriceLamports(additionalLimitBytes, lamportsPerLimitStep) {
const add = BigInt(additionalLimitBytes || 0);
const pricePerStep = BigInt(lamportsPerLimitStep || 0);
if (add < 0n) throw new Error('Некорректный размер увеличения лимита');
if (add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`);
return (add / LIMIT_STEP) * pricePerStep;
}
export function getLimitStepBytes() {
return LIMIT_STEP;
}
@@ -265,6 +265,23 @@ export async function precheckLoginClassOnSolana({ login, solanaEndpoint }) {
return { classCode: classValue, className: 'unknown' };
}
export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const enc = new TextEncoder();
const loginNorm = String(login || '').trim().toLowerCase();
if (!loginNorm) {
throw new Error('EMPTY_LOGIN');
}
const [userPda] = solana.PublicKey.findProgramAddressSync(
[enc.encode('login='), enc.encode(loginNorm)],
usersProgram,
);
const ai = await connection.getAccountInfo(userPda, 'confirmed');
return { exists: !!ai, userPda: userPda.toBase58() };
}
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(solanaEndpoint || ''), 'confirmed');