Files
SHiNE-server/shine-UI/js/services/shine-user-pda-service.js
T

795 lines
38 KiB
JavaScript

import { base58ToBytes, base64ToBytes, bytesToBase58, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from './client-key-utils.js';
import {
SHINE_LOGIN_GUARD_PROGRAM_ID,
SHINE_PAYMENTS_PROGRAM_ID,
SHINE_USERS_ECONOMY_CONFIG_SEED,
SHINE_USERS_PROGRAM_ID,
} from '../solana-programs.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
const MAGIC = 'SHiNE';
const FORMAT_MAJOR = 1;
const FORMAT_MINOR_LEGACY = 0;
const FORMAT_MINOR_CURRENT = 2;
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
const SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX = 'promo_seller=';
const LIMIT_STEP = 10_000n;
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
const PROMO_SIGN_PREFIX = 'shine_promo_v1:';
const BLOCK_TYPE_ROOT_KEY = 1;
const BLOCK_TYPE_CLIENT_KEY = 2;
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
const BLOCK_TYPE_SERVER_PROFILE = 30;
const BLOCK_TYPE_ACCESS_SERVERS = 40;
const AUTH_MODE_BLOCKCHAIN = 0;
const AUTH_MODE_ROOT = 1;
const IX_CREATE_USER_PDA = 3;
const IX_UPDATE_USER_PDA = 4;
const IX_CLOSE_LEGACY_USER_PDA = 6;
const DAY_MS = 86_400_000n;
const FORK_COOLDOWN_MS = 3n * DAY_MS;
const MAX_EFFECTIVE_ACCESS_SERVERS = 1;
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) solanaLibPromise = loadSolanaWeb3();
return solanaLibPromise;
}
function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
function normalizeAccessServers(values) {
const seen = new Set();
const result = [];
for (const value of Array.isArray(values) ? values : []) {
const login = normalizeLogin(value);
if (!login || seen.has(login)) continue;
if (result.length >= MAX_EFFECTIVE_ACCESS_SERVERS) {
throw new Error('PDA 1.2 поддерживает максимум один сервер доступа');
}
seen.add(login);
result.push(login);
}
return result;
}
function toUint8Array(value, expectedLength = null) {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value || []);
if (expectedLength != null && bytes.length !== expectedLength) {
throw new Error(`Ожидалось ${expectedLength} байт, получено ${bytes.length}`);
}
return bytes;
}
function pushU16LE(buf, value) {
const n = Number(value) & 0xffff;
buf.push(n & 0xff, (n >>> 8) & 0xff);
}
function pushU32LE(buf, value) {
const n = Number(value) >>> 0;
buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
}
function pushU64LE(buf, value) {
const b = BigInt(value);
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 pushBytes(buf, bytes) {
for (const x of toUint8Array(bytes)) buf.push(x);
}
function makeReader(bytes) {
let offset = 0;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ensure = (len) => {
if (offset + len > bytes.length) throw new Error('Повреждённый формат PDA');
};
return {
readU8() { ensure(1); return view.getUint8(offset++); },
readU16() { ensure(2); const v = view.getUint16(offset, true); offset += 2; return v; },
readU32() { ensure(4); const v = view.getUint32(offset, true); offset += 4; return v; },
readU64() { ensure(8); const v = view.getBigUint64(offset, true); offset += 8; return v; },
readBytes(len) { ensure(len); const out = bytes.slice(offset, offset + len); offset += len; return out; },
readStrU8() { const len = this.readU8(); return new TextDecoder().decode(this.readBytes(len)); },
skip(len) { ensure(len); offset += len; },
get offset() { return offset; },
get remaining() { return bytes.length - offset; },
};
}
function parseUsersEconomyConfig(dataBytes) {
const view = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength);
if (dataBytes.byteLength < 25) throw new Error('Некорректный economy config');
return {
version: view.getUint8(0),
registrationFeeLamports: view.getBigUint64(1, true),
lamportsPerLimitStep: view.getBigUint64(9, true),
startBonusLimit: view.getBigUint64(17, true),
};
}
function buildEd25519IxData(sig64, pubkey32, messageBytes) {
const sig = toUint8Array(sig64, 64);
const pub = toUint8Array(pubkey32, 32);
const msg = toUint8Array(messageBytes);
const sigOff = 16;
const pkOff = sigOff + 64;
const msgOff = pkOff + 32;
const data = new Uint8Array(msgOff + msg.length);
const view = new DataView(data.buffer);
data[0] = 1;
data[1] = 0;
view.setUint16(2, sigOff, true);
view.setUint16(4, 0xffff, true);
view.setUint16(6, pkOff, true);
view.setUint16(8, 0xffff, true);
view.setUint16(10, msgOff, true);
view.setUint16(12, msg.length, true);
view.setUint16(14, 0xffff, true);
data.set(sig, sigOff);
data.set(pub, pkOff);
data.set(msg, msgOff);
return data;
}
function normalizeServerAddresses(values, fallback = null) {
const src = Array.isArray(values) && values.length ? values : (fallback ? [fallback] : []);
if (src.length > 1) throw new Error('PDA 1.2 поддерживает только один адрес сервера');
return src.map((item) => {
const address = String(item?.address ?? item?.serverAddress ?? '').trim();
const bytes = new TextEncoder().encode(address);
if (!address || bytes.length > 255) throw new Error('Адрес сервера должен занимать 1..255 байт UTF-8');
return {
addressFormatType: Number(item?.addressFormatType ?? 0) & 0xff,
addressFormatVersion: Number(item?.addressFormatVersion ?? 0) & 0xff,
address,
};
});
}
function activeBlockchainCompatibility(login, forks) {
const active = forks.at(-1) || null;
if (!active) return null;
return {
blockchainType: 1,
blockchainName: `${normalizeLogin(login)}-${String(forks.length).padStart(3, '0')}`,
blockchainPublicKey: active.blockchainKey,
paidLimitBytes: BigInt(active.paidLimitBytes),
// Эти поля больше не хранятся в PDA 1.2; оставлены как compatibility view для старого UI.
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash: new Uint8Array(32),
lastBlockSignature: new Uint8Array(64),
arweaveTxId: '',
};
}
function parseV12Pda(bytes, header) {
const reader = makeReader(bytes.slice(header.blocksOffset, header.recordLen - 64));
let rootKey = null;
let clientKey = null;
let forks = null;
let serverAddresses = [];
let accessServers = [];
for (let i = 0; i < header.blocksCount; i += 1) {
const blockType = reader.readU8();
const blockVersion = reader.readU8();
if (blockType === BLOCK_TYPE_ROOT_KEY) {
if (blockVersion !== 0 || rootKey) throw new Error('Некорректный RootKeyBlock');
rootKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_CLIENT_KEY) {
if (blockVersion !== 0 || clientKey) throw new Error('Некорректный ClientKeyBlock');
clientKey = reader.readBytes(32);
continue;
}
const payloadLen = reader.readU16();
const payload = makeReader(reader.readBytes(payloadLen));
if (blockVersion !== 0) continue;
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = payload.readU16();
if (count < 1) throw new Error('BlockchainRegistry не содержит fork');
forks = [];
for (let j = 0; j < count; j += 1) {
forks.push({
blockchainKey: payload.readBytes(32),
createdAtMs: payload.readU64(),
paidLimitBytes: BigInt(payload.readU32()),
});
}
if (payload.remaining !== 0) throw new Error('Повреждённый BlockchainRegistry');
} else if (blockType === BLOCK_TYPE_SERVER_PROFILE) {
const count = payload.readU8();
if (count !== 1) throw new Error('PDA 1.2 допускает ровно один адрес в ServerProfileBlock');
serverAddresses = [];
for (let j = 0; j < count; j += 1) {
serverAddresses.push({
addressFormatType: payload.readU8(),
addressFormatVersion: payload.readU8(),
address: payload.readStrU8(),
});
}
if (payload.remaining !== 0) throw new Error('Повреждённый ServerProfileBlock');
} else if (blockType === BLOCK_TYPE_ACCESS_SERVERS) {
const count = payload.readU8();
if (count > 1) throw new Error('PDA 1.2 допускает максимум один access server');
accessServers = [];
for (let j = 0; j < count; j += 1) accessServers.push(payload.readStrU8());
if (payload.remaining !== 0) throw new Error('Повреждённый AccessServersBlock');
}
}
if (!rootKey || !clientKey || !forks) throw new Error('PDA 1.2 не содержит обязательные блоки');
const firstAddress = serverAddresses[0] || null;
const blockchain = activeBlockchainCompatibility(header.login, forks);
return {
...header,
isLegacy: false,
recoveryKey: null,
rootKey,
clientKey,
forks,
blockchain,
isServer: serverAddresses.length > 0,
serverAddresses,
serverProfile: firstAddress ? { ...firstAddress, serverAddress: firstAddress.address, addresses: serverAddresses } : null,
serverData: firstAddress ? { ...firstAddress, serverAddress: firstAddress.address, addresses: serverAddresses } : null,
addressFormatType: firstAddress?.addressFormatType ?? 0,
addressFormatVersion: firstAddress?.addressFormatVersion ?? 0,
serverAddress: firstAddress?.address ?? '',
syncServers: [],
accessServers: normalizeAccessServers(accessServers),
sessionsMode: 1,
sessions: [],
trustedCount: 0,
archiveHeadTxId: '',
archiveHeadHash: new Uint8Array(32),
};
}
export function parseShineUserPda(dataBytes) {
const bytes = toUint8Array(dataBytes);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
const formatMajor = reader.readU8();
const formatMinor = reader.readU8();
const recordLen = reader.readU16();
if (formatMajor !== FORMAT_MAJOR || formatMinor !== FORMAT_MINOR_CURRENT) throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`);
if (recordLen < 9 + 64 || recordLen > bytes.length) throw new Error('Некорректный record_len');
const createdAtMs = reader.readU64();
const updatedAtMs = reader.readU64();
const recordNumber = reader.readU32();
const prevRecordHash = reader.readBytes(32);
const login = reader.readStrU8();
const blocksCount = reader.readU8();
const blocksOffset = reader.offset;
const signature = bytes.slice(recordLen - 64, recordLen);
const unsignedBytes = bytes.slice(0, recordLen - 64);
const header = { formatMajor, formatMinor, recordLen, createdAtMs, updatedAtMs, recordNumber, prevRecordHash, login, blocksCount, blocksOffset, signature, unsignedBytes };
return parseV12Pda(bytes, header);
}
function writeVariableBlock(out, blockType, writer) {
out.push(blockType, 0);
const lenPos = out.length;
pushU16LE(out, 0);
const start = out.length;
writer(out);
const len = out.length - start;
if (len > 0xffff) throw new Error('Слишком большой payload блока PDA');
out[lenPos] = len & 0xff;
out[lenPos + 1] = (len >>> 8) & 0xff;
}
export function serializeUnsignedRecordFromState(stateLike) {
const state = stateLike || {};
const login = String(state.login || '');
const loginBytes = new TextEncoder().encode(login);
if (loginBytes.length > 255) throw new Error('Слишком длинный логин');
const forks = Array.isArray(state.forks) ? state.forks : [];
if (!forks.length || forks.length > 0xffff) throw new Error('Некорректный список fork');
const serverAddresses = normalizeServerAddresses(state.serverAddresses || []);
const accessServers = normalizeAccessServers(state.accessServers || []);
const out = [];
pushBytes(out, new TextEncoder().encode(MAGIC));
out.push(FORMAT_MAJOR, FORMAT_MINOR_CURRENT);
pushU16LE(out, 0);
pushU64LE(out, BigInt(state.createdAtMs || 0));
pushU64LE(out, BigInt(state.updatedAtMs || 0));
pushU32LE(out, Number(state.recordNumber || 0));
pushBytes(out, toUint8Array(state.prevRecordHash, 32));
pushStrU8(out, login);
let blocksCount = 3;
if (serverAddresses.length) blocksCount += 1;
if (accessServers.length) blocksCount += 1;
out.push(blocksCount);
out.push(BLOCK_TYPE_ROOT_KEY, 0);
pushBytes(out, toUint8Array(state.rootKey, 32));
out.push(BLOCK_TYPE_CLIENT_KEY, 0);
pushBytes(out, toUint8Array(state.clientKey, 32));
writeVariableBlock(out, BLOCK_TYPE_BLOCKCHAIN_REGISTRY, (payload) => {
pushU16LE(payload, forks.length);
for (const fork of forks) {
pushBytes(payload, toUint8Array(fork.blockchainKey, 32));
pushU64LE(payload, BigInt(fork.createdAtMs || 0));
const limit = BigInt(fork.paidLimitBytes || 0);
if (limit < 0n || limit > 0xffffffffn) throw new Error('paid_limit_bytes не помещается в u32');
pushU32LE(payload, Number(limit));
}
});
if (serverAddresses.length) {
writeVariableBlock(out, BLOCK_TYPE_SERVER_PROFILE, (payload) => {
payload.push(serverAddresses.length);
for (const item of serverAddresses) {
payload.push(item.addressFormatType, item.addressFormatVersion);
pushStrU8(payload, item.address);
}
});
}
if (accessServers.length) {
writeVariableBlock(out, BLOCK_TYPE_ACCESS_SERVERS, (payload) => {
payload.push(accessServers.length);
for (const value of accessServers) pushStrU8(payload, value);
});
}
const recordLen = out.length + 64;
if (recordLen > 0xffff) throw new Error('PDA record слишком большой');
out[7] = recordLen & 0xff;
out[8] = (recordLen >>> 8) & 0xff;
return new Uint8Array(out);
}
function serializeCreateUserPdaArgs(args) {
const out = [IX_CREATE_USER_PDA];
pushStrU8(out, args.login);
pushBytes(out, toUint8Array(args.rootKey32, 32));
pushU64LE(out, args.createdAtMs);
pushU64LE(out, BigInt(args.additionalLimitBytes || 0));
pushBytes(out, toUint8Array(args.clientKey32, 32));
pushBytes(out, toUint8Array(args.blockchainKey32, 32));
const addresses = normalizeServerAddresses(args.serverAddresses || []);
out.push(addresses.length);
for (const item of addresses) {
out.push(item.addressFormatType, item.addressFormatVersion);
pushStrU8(out, item.address);
}
const access = normalizeAccessServers(args.accessServers || []);
out.push(access.length);
for (const value of access) pushStrU8(out, value);
pushBytes(out, toUint8Array(args.recordSignature64, 64));
const promo = normalizeLogin(args.promoSellerLogin || '');
if (promo) pushStrU8(out, promo);
return new Uint8Array(out);
}
function serializeUpdateUserPdaArgs(args) {
const out = [IX_UPDATE_USER_PDA];
pushStrU8(out, args.login);
pushBytes(out, toUint8Array(args.rootKey32, 32));
pushU64LE(out, args.updatedAtMs);
pushU64LE(out, BigInt(args.additionalLimitBytes || 0));
pushBytes(out, toUint8Array(args.clientKey32, 32));
out.push(Number(args.authMode) & 0xff);
if (args.newBlockchainKey32) {
out.push(1);
pushBytes(out, toUint8Array(args.newBlockchainKey32, 32));
} else out.push(0);
const addresses = normalizeServerAddresses(args.serverAddresses || []);
out.push(addresses.length);
for (const item of addresses) {
out.push(item.addressFormatType, item.addressFormatVersion);
pushStrU8(out, item.address);
}
const access = normalizeAccessServers(args.accessServers || []);
out.push(access.length);
for (const value of access) pushStrU8(out, value);
pushBytes(out, toUint8Array(args.recordSignature64, 64));
return new Uint8Array(out);
}
function serializeCloseLegacyArgs(login) {
const out = [IX_CLOSE_LEGACY_USER_PDA];
pushStrU8(out, login);
return new Uint8Array(out);
}
export function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber = 0, lastBlockHash32 = new Uint8Array(32), usedBytes = 0n) {
// Legacy helper kept for old UI diagnostics. PDA 1.2 no longer stores blockchain tip state.
const out = [];
pushBytes(out, new TextEncoder().encode('SHiNE_LAST_BLOCK'));
pushStrU8(out, login);
pushStrU8(out, blockchainName);
pushU32LE(out, lastBlockNumber);
pushBytes(out, toUint8Array(lastBlockHash32, 32));
pushU64LE(out, usedBytes);
return new Uint8Array(out);
}
export function buildPromoMessageBytes(login) {
return new TextEncoder().encode(`${PROMO_SIGN_PREFIX}${normalizeLogin(login)}`);
}
export function buildPromoCodeString(sellerLogin, signatureBytes) {
const cleanSellerLogin = normalizeLogin(sellerLogin);
const sig = toUint8Array(signatureBytes, 64);
return `1${cleanSellerLogin}-${bytesToBase58(sig)}`;
}
export async function findPromoSellerPda({ sellerLogin }) {
const cleanSellerLogin = normalizeLogin(sellerLogin);
if (!cleanSellerLogin) throw new Error('Не указан логин продавца');
const solana = await loadSolanaLib();
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [promoSellerPda] = solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanSellerLogin)],
usersProgram,
);
return { sellerLogin: cleanSellerLogin, promoSellerPda: promoSellerPda.toBase58() };
}
export async function getShineUsersEconomyConfig({ solanaEndpoint }) {
const solana = await loadSolanaLib();
const connection = new solana.Connection(String(solanaEndpoint || '').trim(), 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [pda] = solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram);
const info = await connection.getAccountInfo(pda, 'confirmed');
if (!info?.data) throw new Error('Economy config не инициализирован');
return parseUsersEconomyConfig(new Uint8Array(info.data));
}
export async function readShineUserPda({ login, solanaEndpoint }) {
const cleanLogin = normalizeLogin(login);
const solana = await loadSolanaLib();
const endpoint = String(solanaEndpoint || '').trim();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [pda] = solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_USER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanLogin)], usersProgram);
const info = await connection.getAccountInfo(pda, 'confirmed');
if (!info?.data) throw new Error(`PDA пользователя @${cleanLogin} не найдена`);
return { ...parseShineUserPda(new Uint8Array(info.data)), pdaAddress: pda.toBase58(), endpoint };
}
export async function readShineUserPdaByAddress({ pdaAddress, solanaEndpoint }) {
const solana = await loadSolanaLib();
const endpoint = String(solanaEndpoint || '').trim();
const connection = new solana.Connection(endpoint, 'confirmed');
const pubkey = new solana.PublicKey(String(pdaAddress || '').trim());
const info = await connection.getAccountInfo(pubkey, 'confirmed');
if (!info?.data) throw new Error('PDA не найдена');
return { ...parseShineUserPda(new Uint8Array(info.data)), pdaAddress: pubkey.toBase58(), endpoint };
}
export async function readShineUserPdaByRef({ value, solanaEndpoint }) {
const raw = String(value || '').trim();
if (!raw) throw new Error('Не указан логин или PDA');
try { return await readShineUserPdaByAddress({ pdaAddress: raw, solanaEndpoint }); } catch { return readShineUserPda({ login: raw, solanaEndpoint }); }
}
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
const state = await readShineUserPda({ login, solanaEndpoint });
const bch = state.blockchain;
if (!bch) throw new Error('BlockchainRegistry отсутствует');
const usedBytes = state.isLegacy ? BigInt(bch.usedBytes) : 0n;
return {
login: state.login,
blockchainName: bch.blockchainName,
blockchainPublicKey: bch.blockchainPublicKey,
paidLimitBytes: BigInt(bch.paidLimitBytes),
usedBytes,
leftBytes: BigInt(bch.paidLimitBytes) > usedBytes ? BigInt(bch.paidLimitBytes) - usedBytes : 0n,
lastBlockNumber: state.isLegacy ? bch.lastBlockNumber : 0,
lastBlockHash: state.isLegacy ? bch.lastBlockHash : new Uint8Array(32),
usageStoredInPda: state.isLegacy,
pdaState: state,
};
}
async function attachSolanaLogs(error, connection) {
try {
if (typeof error?.getLogs === 'function') error.solanaLogs = await error.getLogs(connection);
} catch {}
return error;
}
async function buildCommonContext({ login, clientPrivatePkcs8B64, payerPrivatePkcs8B64 = '', solanaEndpoint }) {
const cleanLogin = normalizeLogin(login);
const solana = await loadSolanaLib();
const endpoint = String(solanaEndpoint || '').trim();
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 loginGuardProgram = new solana.PublicKey(SHINE_LOGIN_GUARD_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(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram);
const [economyConfigPda] = 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 payerPkcs8 = String(payerPrivatePkcs8B64 || clientPrivatePkcs8B64 || '');
if (!payerPkcs8) throw new Error('Нужен private key Solana fee payer');
const payerSeed32 = extractSeed32FromPkcs8B64(payerPkcs8);
const payerKeypair = solana.Keypair.fromSeed(payerSeed32);
// Имя clientKeypair сохранено в возвращаемом объекте для совместимости текущих UI-callers;
// фактически это произвольный fee payer и он не обязан совпадать с ClientKeyBlock.
return { cleanLogin, endpoint, solana, connection, usersProgram, paymentsProgram, loginGuardProgram, ed25519Program, sysvarInstructions, userPda, economyConfigPda, inflowVault, clientKeypair: payerKeypair, payerKeypair };
}
async function createShineUserPdaOnSolana({
login,
keyBundle,
solanaEndpoint,
serverAddresses = [],
isServer = false,
addressFormatType = 0,
addressFormatVersion = 0,
serverAddress = '',
accessServers = [],
promoCode = '',
}) {
const rawLogin = String(login || '').trim();
const cleanLogin = normalizeLogin(rawLogin);
const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64);
const rootPriv = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
const bchPriv = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
const ctx = await buildCommonContext({ login: cleanLogin, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, solanaEndpoint });
const ecoInfo = await ctx.connection.getAccountInfo(ctx.economyConfigPda, 'confirmed');
if (!ecoInfo?.data) throw new Error('Economy config не инициализирован.');
const startBonusLimit = parseUsersEconomyConfig(new Uint8Array(ecoInfo.data)).startBonusLimit;
if (startBonusLimit > 0xffffffffn) throw new Error('Стартовый лимит не помещается в PDA 1.2 u32');
const createdAtMs = BigInt(Date.now());
const addresses = normalizeServerAddresses(serverAddresses, isServer ? { addressFormatType, addressFormatVersion, address: serverAddress } : null);
const initial = {
login: rawLogin,
createdAtMs,
updatedAtMs: createdAtMs,
recordNumber: 0,
prevRecordHash: new Uint8Array(32),
rootKey: rootKey32,
clientKey: clientKey32,
forks: [{ blockchainKey: blockchainKey32, createdAtMs, paidLimitBytes: startBonusLimit }],
serverAddresses: addresses,
accessServers,
};
const unsigned = serializeUnsignedRecordFromState(initial);
const hash = await sha256Bytes(unsigned);
const rootSig = await signBytes(rootPriv, hash);
const recordSig = await signBytes(bchPriv, hash);
let promoSellerPda = null;
let promoEdIx = null;
let promoSellerLogin = '';
const cleanPromoCode = String(promoCode || '').trim();
if (cleanPromoCode) {
const dash = cleanPromoCode.indexOf('-');
if (!cleanPromoCode.startsWith('1') || dash <= 1 || dash === cleanPromoCode.length - 1) throw new Error('Некорректный формат промокода');
promoSellerLogin = normalizeLogin(cleanPromoCode.slice(1, dash));
const sig = base58ToBytes(cleanPromoCode.slice(dash + 1));
const [sellerPda] = ctx.solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(promoSellerLogin)], ctx.usersProgram);
promoSellerPda = sellerPda;
const sellerInfo = await ctx.connection.getAccountInfo(sellerPda, 'confirmed');
if (!sellerInfo?.data || sellerInfo.data.length < 42) throw new Error('Promo seller PDA не найдена');
promoEdIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(sig, new Uint8Array(sellerInfo.data.slice(10, 42)), buildPromoMessageBytes(cleanLogin)) });
}
const ixData = serializeCreateUserPdaArgs({ login: rawLogin, rootKey32, createdAtMs, additionalLimitBytes: 0n, clientKey32, blockchainKey32, serverAddresses: addresses, accessServers, recordSignature64: recordSig, promoSellerLogin });
const rootIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(rootSig, rootKey32, hash) });
const recordIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(recordSig, blockchainKey32, hash) });
const createIx = new ctx.solana.TransactionInstruction({
programId: ctx.usersProgram,
keys: [
{ pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: ctx.userPda, isSigner: false, isWritable: true },
{ pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
{ pubkey: ctx.sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: ctx.economyConfigPda, isSigner: false, isWritable: false },
{ pubkey: ctx.loginGuardProgram, isSigner: false, isWritable: false },
...(promoSellerPda ? [{ pubkey: promoSellerPda, isSigner: false, isWritable: true }] : []),
],
data: ixData,
});
try {
const tx = new ctx.solana.Transaction();
if (promoEdIx) tx.add(promoEdIx);
tx.add(rootIx, recordIx, createIx);
const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, tx, [ctx.clientKeypair], { commitment: 'confirmed' });
return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), blockchainName: `${cleanLogin}-001` };
} catch (error) { throw await attachSolanaLogs(error, ctx.connection); }
}
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [], promoCode = '' }) {
return createShineUserPdaOnSolana({ login, keyBundle, solanaEndpoint, accessServers, promoCode });
}
export async function registerServerOnSolana({ login, keyBundle, serverAddress, serverAddresses = null, addressFormatType = 1, addressFormatVersion = 0, accessServers = [], solanaEndpoint }) {
return createShineUserPdaOnSolana({ login, keyBundle, solanaEndpoint, isServer: true, serverAddress, serverAddresses: serverAddresses || [], addressFormatType, addressFormatVersion, accessServers });
}
export async function closeLegacyShineUserPdaOnSolana({ login, solanaEndpoint, callerPrivatePkcs8B64 }) {
const cleanLogin = normalizeLogin(login);
if (!cleanLogin || !callerPrivatePkcs8B64) throw new Error('Нужны login и private key вызывающего');
const solana = await loadSolanaLib();
const endpoint = String(solanaEndpoint || '').trim();
const connection = new solana.Connection(endpoint, 'confirmed');
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
const [userPda] = solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_USER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanLogin)], usersProgram);
const info = await connection.getAccountInfo(userPda, 'confirmed');
if (!info) throw new Error('PDA не найдена');
const raw = new Uint8Array(info.data);
if (raw.length < 7 || new TextDecoder().decode(raw.slice(0, 5)) !== MAGIC || raw[5] !== FORMAT_MAJOR || raw[6] !== FORMAT_MINOR_LEGACY) {
throw new Error('Закрывать этой инструкцией можно только legacy PDA 1.0');
}
const seed32 = extractSeed32FromPkcs8B64(callerPrivatePkcs8B64);
const caller = solana.Keypair.fromSeed(seed32);
const ix = new solana.TransactionInstruction({
programId: usersProgram,
keys: [
{ pubkey: caller.publicKey, isSigner: true, isWritable: true },
{ pubkey: userPda, isSigner: false, isWritable: true },
],
data: serializeCloseLegacyArgs(cleanLogin),
});
const signature = await solana.sendAndConfirmTransaction(connection, new solana.Transaction().add(ix), [caller], { commitment: 'confirmed' });
return { signature, pdaAddress: userPda.toBase58() };
}
export async function updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64,
clientPrivatePkcs8B64,
payerPrivatePkcs8B64 = '',
blockchainPrivatePkcs8B64,
authorityMode,
additionalLimitBytes = 0n,
newRootPublicKey32 = null,
newRootPrivatePkcs8B64 = '',
nextClientPublicKey32 = null,
newBlockchainPublicKey32 = null,
newBlockchainPrivatePkcs8B64 = '',
serverProfile,
serverAddresses,
accessServers,
}) {
const current = await readShineUserPda({ login, solanaEndpoint });
const ctx = await buildCommonContext({ login: current.login, clientPrivatePkcs8B64, payerPrivatePkcs8B64, solanaEndpoint });
const addLimit = BigInt(additionalLimitBytes || 0);
if (addLimit < 0n || addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
const authMode = authorityMode === 'blockchain' || authorityMode === AUTH_MODE_BLOCKCHAIN ? AUTH_MODE_BLOCKCHAIN : AUTH_MODE_ROOT;
const rootKey = newRootPublicKey32 ? toUint8Array(newRootPublicKey32, 32) : current.rootKey;
const clientKey = nextClientPublicKey32 ? toUint8Array(nextClientPublicKey32, 32) : current.clientKey;
const rootChanged = bytesToBase58(rootKey) !== bytesToBase58(current.rootKey);
if (authMode === AUTH_MODE_BLOCKCHAIN && rootChanged) throw new Error('Blockchain authority не может менять root key');
if (rootChanged && newBlockchainPublicKey32) throw new Error('Root rotation и blockchain fork нужно делать разными транзакциями');
const updatedAtMs = BigInt(Date.now());
const forks = current.forks.map((fork) => ({ blockchainKey: fork.blockchainKey, createdAtMs: BigInt(fork.createdAtMs), paidLimitBytes: BigInt(fork.paidLimitBytes) }));
let appendedKey = null;
if (newBlockchainPublicKey32) {
appendedKey = toUint8Array(newBlockchainPublicKey32, 32);
if (forks.some((f) => bytesToBase58(f.blockchainKey) === bytesToBase58(appendedKey))) throw new Error('Этот blockchain key уже был в истории fork');
const lastForkAt = BigInt(forks.at(-1).createdAtMs);
if (authMode === AUTH_MODE_BLOCKCHAIN && updatedAtMs - lastForkAt < FORK_COOLDOWN_MS) throw new Error('Новый fork обычным blockchain authority можно создавать не чаще одного раза в 72 часа');
const newLimit = forks.at(-1).paidLimitBytes + addLimit;
if (newLimit > 0xffffffffn) throw new Error('paid_limit_bytes превышает u32');
forks.push({ blockchainKey: appendedKey, createdAtMs: updatedAtMs, paidLimitBytes: newLimit });
} else if (addLimit > 0n) {
const active = forks.at(-1);
active.paidLimitBytes += addLimit;
if (active.paidLimitBytes > 0xffffffffn) throw new Error('paid_limit_bytes превышает u32');
}
let addresses = current.serverAddresses;
if (serverAddresses != null) addresses = normalizeServerAddresses(serverAddresses);
else if (serverProfile != null) {
if (Array.isArray(serverProfile.addresses)) addresses = normalizeServerAddresses(serverProfile.addresses);
else if (serverProfile.serverAddress) addresses = normalizeServerAddresses([], { addressFormatType: serverProfile.addressFormatType ?? current.addressFormatType, addressFormatVersion: serverProfile.addressFormatVersion ?? current.addressFormatVersion, address: serverProfile.serverAddress });
else addresses = [];
}
const nextAccess = accessServers == null ? current.accessServers : normalizeAccessServers(accessServers);
const next = {
login: current.login,
createdAtMs: current.createdAtMs,
updatedAtMs,
recordNumber: current.recordNumber + 1,
prevRecordHash: await sha256Bytes(current.unsignedBytes),
rootKey,
clientKey,
forks,
serverAddresses: addresses,
accessServers: nextAccess,
};
const unsigned = serializeUnsignedRecordFromState(next);
const hash = await sha256Bytes(unsigned);
const oldAuthorityPub = authMode === AUTH_MODE_ROOT ? current.rootKey : current.forks.at(-1).blockchainKey;
const oldAuthorityPrivB64 = authMode === AUTH_MODE_ROOT ? rootPrivatePkcs8B64 : blockchainPrivatePkcs8B64;
if (!oldAuthorityPrivB64) throw new Error(authMode === AUTH_MODE_ROOT ? 'Нужен root private key' : 'Нужен blockchain private key');
const oldAuthorityPriv = await importPkcs8Ed25519(oldAuthorityPrivB64);
const authSig = await signBytes(oldAuthorityPriv, hash);
let recordSignerPub;
let recordSignerPrivB64;
if (rootChanged) {
recordSignerPub = rootKey;
recordSignerPrivB64 = newRootPrivatePkcs8B64;
} else if (appendedKey) {
recordSignerPub = appendedKey;
recordSignerPrivB64 = newBlockchainPrivatePkcs8B64;
} else if (authMode === AUTH_MODE_ROOT) {
recordSignerPub = rootKey;
recordSignerPrivB64 = rootPrivatePkcs8B64;
} else {
recordSignerPub = current.forks.at(-1).blockchainKey;
recordSignerPrivB64 = blockchainPrivatePkcs8B64;
}
if (!recordSignerPrivB64) throw new Error('Нет приватного ключа нового authority для подписи новой PDA');
const recordSignerPriv = await importPkcs8Ed25519(recordSignerPrivB64);
const recordSig = await signBytes(recordSignerPriv, hash);
const authIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(authSig, oldAuthorityPub, hash) });
const recordIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(recordSig, recordSignerPub, hash) });
const updateIx = new ctx.solana.TransactionInstruction({
programId: ctx.usersProgram,
keys: [
{ pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true },
{ pubkey: ctx.userPda, isSigner: false, isWritable: true },
{ pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: ctx.inflowVault, isSigner: false, isWritable: true },
{ pubkey: ctx.sysvarInstructions, isSigner: false, isWritable: false },
{ pubkey: ctx.economyConfigPda, isSigner: false, isWritable: false },
],
data: serializeUpdateUserPdaArgs({ login: current.login, rootKey32: rootKey, updatedAtMs, additionalLimitBytes: addLimit, clientKey32: clientKey, authMode, newBlockchainKey32: appendedKey, serverAddresses: addresses, accessServers: nextAccess, recordSignature64: recordSig }),
});
try {
const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, new ctx.solana.Transaction().add(authIx, recordIx, updateIx), [ctx.clientKeypair], { commitment: 'confirmed' });
return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), paidLimitBytes: forks.at(-1).paidLimitBytes, forkCount: forks.length };
} catch (error) { throw await attachSolanaLogs(error, ctx.connection); }
}
export async function updateServerOnSolana({ login, keyBundle, serverAddress, serverAddresses = null, addressFormatType = 1, addressFormatVersion = 0, solanaEndpoint }) {
return updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
authorityMode: 'root',
serverAddresses: serverAddresses || [{ addressFormatType, addressFormatVersion, address: serverAddress }],
});
}
export function calcLimitTopupPriceLamports(additionalLimitBytes, lamportsPerLimitStep) {
const add = BigInt(additionalLimitBytes || 0);
const pricePerStep = BigInt(lamportsPerLimitStep || 0);
if (add < 0n || add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`);
return (add / LIMIT_STEP) * pricePerStep;
}
export function getLimitStepBytes() { return LIMIT_STEP; }