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; }