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

1230 lines
43 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 LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
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 BLOCKCHAIN_TYPE_MAIN_USER = 1;
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
const PROMO_SIGN_PREFIX = 'shine_promo_v1:';
const BLOCK_TYPE_RECOVERY_KEY = 0;
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 BLOCK_TYPE_SESSIONS = 50;
const BLOCK_TYPE_TRUSTED_STATE = 70;
const SESSIONS_MODE_MIXED = 1;
const SESSION_TYPE_USER = 1;
const SESSION_TYPE_HOMESERVER = 100;
let solanaLibPromise = null;
function loadSolanaLib() {
if (!solanaLibPromise) solanaLibPromise = loadSolanaWeb3();
return solanaLibPromise;
}
function normalizeLogin(login) {
return String(login || '').trim().toLowerCase();
}
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 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 value of arr) pushStrU32(buf, value);
}
function pushSessionRecordsU32(buf, sessions) {
const arr = Array.isArray(sessions) ? sessions : [];
pushU32LE(buf, arr.length);
for (const session of arr) {
buf.push(Number(session?.sessionType || 0) & 0xff);
buf.push(Number(session?.sessionVersion || 0) & 0xff);
pushStrU32(buf, String(session?.sessionName || ''));
const key = session?.sessionPubKey32 || new Uint8Array(32);
for (const x of key) 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');
};
const readU8 = () => {
ensure(1);
const value = view.getUint8(offset);
offset += 1;
return value;
};
const readU16 = () => {
ensure(2);
const value = view.getUint16(offset, true);
offset += 2;
return value;
};
const readU32 = () => {
ensure(4);
const value = view.getUint32(offset, true);
offset += 4;
return value;
};
const readU64 = () => {
ensure(8);
const value = view.getBigUint64(offset, true);
offset += 8;
return value;
};
const readBytes = (len) => {
ensure(len);
const out = bytes.slice(offset, offset + len);
offset += len;
return out;
};
const readStrU8 = () => {
const len = readU8();
return new TextDecoder().decode(readBytes(len));
};
return {
readU8,
readU16,
readU32,
readU64,
readBytes,
readStrU8,
get offset() {
return 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 sigOff = 16;
const pkOff = sigOff + 64;
const msgOff = pkOff + 32;
const msg = messageBytes instanceof Uint8Array ? messageBytes : new Uint8Array(messageBytes || []);
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(sig64, sigOff);
data.set(pubkey32, pkOff);
data.set(msg, msgOff);
return data;
}
function serializeCreateUserPdaArgs(args) {
const buf = [];
buf.push(3);
pushStrU8(buf, args.login);
for (const x of args.recoveryKey32) buf.push(x);
for (const x of args.rootKey32) buf.push(x);
pushU64LE(buf, args.createdAtMs);
pushU64LE(buf, BigInt(args.additionalLimitBytes || 0n));
for (const x of args.clientKey32) buf.push(x);
for (const x of args.blockchainPublicKey32) buf.push(x);
pushStrU8(buf, args.blockchainName);
pushU64LE(buf, args.usedBytes);
pushU32LE(buf, args.lastBlockNumber);
for (const x of args.lastBlockHash32) buf.push(x);
for (const x of args.lastBlockSignature64) buf.push(x);
pushStrU8(buf, args.arweaveTxId);
buf.push(args.isServer ? 1 : 0);
if (args.isServer) {
buf.push(Number(args.addressFormatType || 0) & 0xff);
buf.push(Number(args.addressFormatVersion || 0) & 0xff);
pushStrU8(buf, args.serverAddress);
const syncServers = Array.isArray(args.syncServers) ? args.syncServers : [];
buf.push(syncServers.length & 0xff);
for (const value of syncServers) pushStrU8(buf, value);
}
const accessServers = Array.isArray(args.accessServers) ? args.accessServers : [];
buf.push(accessServers.length & 0xff);
for (const value of accessServers) pushStrU8(buf, value);
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
const sessions = Array.isArray(args.sessions) ? args.sessions : [];
buf.push(sessions.length & 0xff);
for (const session of sessions) {
buf.push(Number(session?.sessionType || 0) & 0xff);
buf.push(Number(session?.sessionVersion || 0) & 0xff);
pushStrU8(buf, String(session?.sessionName || ''));
const key = session?.sessionPubKey32 || new Uint8Array(32);
for (const x of key) buf.push(x);
}
buf.push(Number(args.trustedCount || 0) & 0xff);
for (const x of args.rootSignature64) buf.push(x);
const promoSellerLogin = normalizeLogin(String(args.promoSellerLogin || '').trim());
if (promoSellerLogin) {
pushStrU8(buf, promoSellerLogin);
}
return new Uint8Array(buf);
}
function serializeUpdateUserPdaArgs(args) {
const buf = [];
buf.push(4);
pushStrU8(buf, args.login);
for (const x of args.recoveryKey32) buf.push(x);
for (const x of args.rootKey32) buf.push(x);
pushU64LE(buf, args.createdAtMs);
pushU64LE(buf, args.updatedAtMs);
pushU32LE(buf, args.version);
for (const x of args.prevHash32) buf.push(x);
pushU64LE(buf, args.additionalLimitBytes);
for (const x of args.clientKey32) buf.push(x);
for (const x of args.blockchainPublicKey32) buf.push(x);
pushStrU8(buf, args.blockchainName);
pushU64LE(buf, args.usedBytes);
pushU32LE(buf, args.lastBlockNumber);
for (const x of args.lastBlockHash32) buf.push(x);
for (const x of args.lastBlockSignature64) buf.push(x);
pushStrU8(buf, args.arweaveTxId);
buf.push(args.isServer ? 1 : 0);
if (args.isServer) {
buf.push(Number(args.addressFormatType || 0) & 0xff);
buf.push(Number(args.addressFormatVersion || 0) & 0xff);
pushStrU8(buf, args.serverAddress);
const syncServers = Array.isArray(args.syncServers) ? args.syncServers : [];
buf.push(syncServers.length & 0xff);
for (const value of syncServers) pushStrU8(buf, value);
}
const accessServers = Array.isArray(args.accessServers) ? args.accessServers : [];
buf.push(accessServers.length & 0xff);
for (const value of accessServers) pushStrU8(buf, value);
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
const sessions = Array.isArray(args.sessions) ? args.sessions : [];
buf.push(sessions.length & 0xff);
for (const session of sessions) {
buf.push(Number(session?.sessionType || 0) & 0xff);
buf.push(Number(session?.sessionVersion || 0) & 0xff);
pushStrU8(buf, String(session?.sessionName || ''));
const key = session?.sessionPubKey32 || new Uint8Array(32);
for (const x of key) buf.push(x);
}
buf.push(Number(args.trustedCount || 0) & 0xff);
for (const x of args.rootSignature64) buf.push(x);
return new Uint8Array(buf);
}
function createBlockchainState({
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
}) {
return {
blockchainType: BLOCKCHAIN_TYPE_MAIN_USER,
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
};
}
function createPdaState({
login,
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
recoveryKey,
rootKey,
clientKey,
blockchain,
isServer,
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
accessServers,
sessionsMode,
sessions,
trustedCount,
}) {
const serverProfile = isServer ? {
addressFormatType: Number(addressFormatType || 0),
addressFormatVersion: Number(addressFormatVersion || 0),
serverAddress: String(serverAddress || ''),
syncServers: Array.isArray(syncServers) ? [...syncServers] : [],
} : null;
return {
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
login,
recoveryKey,
rootKey,
clientKey,
clientKey: clientKey,
blockchain,
isServer: Boolean(isServer),
serverProfile,
serverData: serverProfile,
addressFormatType: serverProfile?.addressFormatType ?? 0,
addressFormatVersion: serverProfile?.addressFormatVersion ?? 0,
serverAddress: serverProfile?.serverAddress ?? '',
syncServers: serverProfile?.syncServers ? [...serverProfile.syncServers] : [],
accessServers: Array.isArray(accessServers) ? [...accessServers] : [],
sessionsMode: Number(sessionsMode || SESSIONS_MODE_MIXED) & 0xff,
sessions: Array.isArray(sessions) ? sessions.map((x) => ({
sessionType: Number(x?.sessionType || 0) & 0xff,
sessionVersion: Number(x?.sessionVersion || 0) & 0xff,
sessionName: String(x?.sessionName || ''),
sessionPubKey32: x?.sessionPubKey32 instanceof Uint8Array ? x.sessionPubKey32 : new Uint8Array(x?.sessionPubKey32 || 32),
})) : [],
trustedCount: Number(trustedCount || 0) & 0xff,
};
}
function encodeOptionalArweave(buf, arweaveTxId) {
const value = String(arweaveTxId || '').trim();
if (value) {
buf.push(1);
pushStrU8(buf, value);
} else {
buf.push(0);
}
}
export function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber = 0, lastBlockHash32 = new Uint8Array(32), usedBytes = 0n) {
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);
}
export function buildPromoMessageBytes(login) {
return new TextEncoder().encode(`${PROMO_SIGN_PREFIX}${normalizeLogin(login)}`);
}
export function buildPromoCodeString(sellerLogin, signatureBytes) {
const cleanSellerLogin = normalizeLogin(sellerLogin);
if (!cleanSellerLogin) {
throw new Error('Не указан логин продавца');
}
const sig = signatureBytes instanceof Uint8Array ? signatureBytes : new Uint8Array(signatureBytes || []);
if (sig.length !== 64) {
throw new Error('Подпись промокода должна быть ровно 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 function parseShineUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
const reader = makeReader(bytes);
const magic = new TextDecoder().decode(reader.readBytes(5));
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
reader.readU8();
reader.readU8();
const recordLen = reader.readU16();
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();
let recoveryKey = null;
let rootKey = null;
let clientKey = null;
let blockchain = null;
let isServer = false;
let addressFormatType = 0;
let addressFormatVersion = 0;
let serverAddress = '';
let syncServers = [];
let accessServers = [];
let sessionsMode = SESSIONS_MODE_MIXED;
let sessions = [];
let trustedCount = 0;
for (let i = 0; i < blocksCount; i += 1) {
const blockType = reader.readU8();
reader.readU8();
if (blockType === BLOCK_TYPE_RECOVERY_KEY) {
recoveryKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_ROOT_KEY) {
rootKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_CLIENT_KEY) {
clientKey = reader.readBytes(32);
continue;
}
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
const count = reader.readU8();
for (let j = 0; j < count; j += 1) {
const blockchainType = reader.readU8();
const blockchainName = reader.readStrU8();
const blockchainPublicKey = reader.readBytes(32);
const paidLimitBytes = reader.readU64();
const usedBytes = reader.readU64();
const lastBlockNumber = reader.readU32();
const lastBlockHash = reader.readBytes(32);
const lastBlockSignature = reader.readBytes(64);
const arweavePresent = reader.readU8();
const arweaveTxId = arweavePresent === 1 ? reader.readStrU8() : '';
if (!blockchain) {
blockchain = {
blockchainType,
blockchainName,
blockchainPublicKey,
paidLimitBytes,
usedBytes,
lastBlockNumber,
lastBlockHash,
lastBlockSignature,
arweaveTxId,
};
}
}
continue;
}
if (blockType === BLOCK_TYPE_SERVER_PROFILE) {
isServer = reader.readU8() === 1;
if (isServer) {
addressFormatType = reader.readU8();
addressFormatVersion = reader.readU8();
serverAddress = reader.readStrU8();
const syncCount = reader.readU8();
syncServers = [];
for (let j = 0; j < syncCount; j += 1) syncServers.push(reader.readStrU8());
}
continue;
}
if (blockType === BLOCK_TYPE_ACCESS_SERVERS) {
const accessCount = reader.readU8();
accessServers = [];
for (let j = 0; j < accessCount; j += 1) accessServers.push(reader.readStrU8());
continue;
}
if (blockType === BLOCK_TYPE_SESSIONS) {
sessionsMode = reader.readU8();
const sessionsCount = reader.readU8();
sessions = [];
for (let j = 0; j < sessionsCount; j += 1) {
sessions.push({
sessionType: reader.readU8(),
sessionVersion: reader.readU8(),
sessionName: reader.readStrU8(),
sessionPubKey32: reader.readBytes(32),
});
}
continue;
}
if (blockType === BLOCK_TYPE_TRUSTED_STATE) {
trustedCount = reader.readU8();
continue;
}
throw new Error(`Неизвестный блок PDA: ${blockType}`);
}
if (!recoveryKey || !rootKey || !clientKey || !blockchain) {
throw new Error('В PDA отсутствуют обязательные блоки');
}
const signature = bytes.slice(reader.offset, reader.offset + 64);
const unsignedBytes = bytes.slice(0, recordLen - 64);
const state = createPdaState({
login,
createdAtMs,
updatedAtMs,
recordNumber,
prevRecordHash,
recoveryKey,
rootKey,
clientKey,
blockchain,
isServer,
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
accessServers,
sessionsMode,
sessions,
trustedCount,
});
return {
...state,
recordLen,
unsignedBytes,
signature,
};
}
export function serializeUnsignedRecordFromState(stateLike) {
const state = createPdaState({
login: stateLike.login,
createdAtMs: stateLike.createdAtMs,
updatedAtMs: stateLike.updatedAtMs,
recordNumber: stateLike.recordNumber,
prevRecordHash: stateLike.prevRecordHash,
recoveryKey: stateLike.recoveryKey,
rootKey: stateLike.rootKey,
clientKey: stateLike.clientKey ?? stateLike.clientKey,
blockchain: stateLike.blockchain,
isServer: stateLike.isServer,
addressFormatType: stateLike.addressFormatType ?? stateLike.serverProfile?.addressFormatType,
addressFormatVersion: stateLike.addressFormatVersion ?? stateLike.serverProfile?.addressFormatVersion,
serverAddress: stateLike.serverAddress ?? stateLike.serverProfile?.serverAddress,
syncServers: stateLike.syncServers ?? stateLike.serverProfile?.syncServers,
accessServers: stateLike.accessServers,
sessionsMode: stateLike.sessionsMode,
sessions: stateLike.sessions,
trustedCount: stateLike.trustedCount,
});
const buf = [0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0];
pushU64LE(buf, state.createdAtMs);
pushU64LE(buf, state.updatedAtMs);
pushU32LE(buf, state.recordNumber);
for (const x of state.prevRecordHash) buf.push(x);
pushStrU8(buf, state.login);
buf.push(state.isServer ? 8 : 7);
buf.push(BLOCK_TYPE_RECOVERY_KEY, 0);
for (const x of state.recoveryKey) buf.push(x);
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
for (const x of state.rootKey) buf.push(x);
buf.push(BLOCK_TYPE_CLIENT_KEY, 0);
for (const x of state.clientKey) buf.push(x);
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1, state.blockchain.blockchainType);
pushStrU8(buf, state.blockchain.blockchainName);
for (const x of state.blockchain.blockchainPublicKey) buf.push(x);
pushU64LE(buf, state.blockchain.paidLimitBytes);
pushU64LE(buf, state.blockchain.usedBytes);
pushU32LE(buf, state.blockchain.lastBlockNumber);
for (const x of state.blockchain.lastBlockHash) buf.push(x);
for (const x of state.blockchain.lastBlockSignature) buf.push(x);
encodeOptionalArweave(buf, state.blockchain.arweaveTxId);
if (state.isServer) {
buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1);
buf.push(state.addressFormatType & 0xff);
buf.push(state.addressFormatVersion & 0xff);
pushStrU8(buf, state.serverAddress);
buf.push(state.syncServers.length & 0xff);
for (const loginValue of state.syncServers) pushStrU8(buf, loginValue);
}
buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0, state.accessServers.length & 0xff);
for (const loginValue of state.accessServers) pushStrU8(buf, loginValue);
buf.push(BLOCK_TYPE_SESSIONS, 0, state.sessionsMode & 0xff, state.sessions.length & 0xff);
for (const session of state.sessions) {
buf.push(session.sessionType & 0xff, session.sessionVersion & 0xff);
pushStrU8(buf, session.sessionName);
for (const x of session.sessionPubKey32) buf.push(x);
}
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, state.trustedCount & 0xff);
const recordLen = buf.length + 64;
buf[7] = recordLen & 0xff;
buf[8] = (recordLen >>> 8) & 0xff;
return new Uint8Array(buf);
}
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 accountInfo = await connection.getAccountInfo(economyPda, 'confirmed');
if (!accountInfo?.data) throw new Error('Economy config PDA не найден');
return { endpoint, economyPda: economyPda.toBase58(), ...parseUsersEconomyConfig(accountInfo.data) };
}
export async function readShineUserPda({ login, solanaEndpoint }) {
const cleanLogin = normalizeLogin(login);
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(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram);
const accountInfo = await connection.getAccountInfo(userPda, 'confirmed');
if (!accountInfo?.data) throw new Error(`PDA не найдена для логина «${cleanLogin}»`);
return {
...parseShineUserPda(accountInfo.data),
userPda: userPda.toBase58(),
pdaAddress: userPda.toBase58(),
endpoint,
};
}
export async function readShineUserPdaByAddress({ pdaAddress, solanaEndpoint }) {
const address = String(pdaAddress || '').trim();
const endpoint = String(solanaEndpoint || '').trim();
if (!address) throw new Error('Не указан адрес PDA');
if (!endpoint) throw new Error('Не указан Solana RPC endpoint');
const solana = await loadSolanaLib();
const connection = new solana.Connection(endpoint, 'confirmed');
const accountAddress = new solana.PublicKey(address);
const accountInfo = await connection.getAccountInfo(accountAddress, 'confirmed');
if (!accountInfo?.data) throw new Error(`PDA не найдена: ${address}`);
return {
...parseShineUserPda(accountInfo.data),
userPda: accountAddress.toBase58(),
pdaAddress: accountAddress.toBase58(),
endpoint,
};
}
export async function readShineUserPdaByRef({ value, solanaEndpoint }) {
const ref = String(value || '').trim();
if (!ref) throw new Error('Не указан логин или адрес PDA');
try {
const bytes = base58ToBytes(ref);
if (bytes.length === 32) {
return await readShineUserPdaByAddress({ pdaAddress: ref, solanaEndpoint });
}
} catch {
// Если это не адрес, читаем как логин.
}
return readShineUserPda({ login: ref, solanaEndpoint });
}
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
const parsed = await readShineUserPda({ login, solanaEndpoint });
const bch = parsed.blockchain;
const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n;
return {
endpoint: parsed.endpoint,
userPda: parsed.userPda,
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(''),
};
}
function parseHex32(value) {
const clean = String(value || '').trim().toLowerCase();
if (!clean) return null;
if (!/^[0-9a-f]+$/.test(clean) || clean.length !== 64) {
throw new Error('last block hash должен быть 32 байта в hex');
}
const out = new Uint8Array(32);
for (let i = 0; i < 32; i += 1) {
out[i] = parseInt(clean.slice(i * 2, (i * 2) + 2), 16);
}
return out;
}
async function attachSolanaLogs(error, connection) {
if (!error || typeof error.getLogs !== 'function' || !connection) {
return error;
}
try {
const logs = await error.getLogs(connection);
if (Array.isArray(logs) && logs.length) {
error.logs = logs;
error.transactionLogs = logs;
error.simulationLogs = logs;
if (!String(error.message || '').includes('Logs:')) {
error.message = `${String(error.message || 'Solana transaction failed')} :: Logs: ${logs.join(' | ')}`;
}
}
} catch {
// Если RPC не вернул логи, оставляем исходную ошибку как есть.
}
return error;
}
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const cleanLogin = normalizeLogin(login);
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 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 recoveryKey32 = base64ToBytes(keyBundle.recoveryPair.publicKeyB64);
const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64);
const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
const clientSeed32 = extractSeed32FromPkcs8B64(keyBundle.clientPair.privatePkcs8B64);
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
return {
cleanLogin,
endpoint,
solana,
connection,
usersProgram,
paymentsProgram,
loginGuardProgram,
ed25519Program,
sysvarInstructions,
userPda,
economyConfigPda,
inflowVault,
recoveryKey32,
rootKey32,
blockchainKey32,
clientKey32,
rootPrivKey,
bchPrivKey,
clientKeypair,
};
}
async function createShineUserPdaOnSolana({
login,
keyBundle,
solanaEndpoint,
isServer = false,
addressFormatType = 0,
addressFormatVersion = 0,
serverAddress = '',
syncServers = [],
accessServers = [],
promoCode = '',
}) {
const ctx = await buildCreateContext({ login, keyBundle, solanaEndpoint });
const ecoAccount = await ctx.connection.getAccountInfo(ctx.economyConfigPda);
if (!ecoAccount?.data) {
throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.');
}
const cleanLogin = ctx.cleanLogin;
const cleanPromoCode = String(promoCode || '').trim();
const blockchainName = `${cleanLogin}-001`;
const zeroHash32 = new Uint8Array(32);
const createdAtMs = BigInt(Date.now());
const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit;
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, zeroHash32, 0n);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash);
const initialState = createPdaState({
login: cleanLogin,
createdAtMs,
updatedAtMs: createdAtMs,
recordNumber: 0,
prevRecordHash: zeroHash32,
recoveryKey: ctx.recoveryKey32,
rootKey: ctx.rootKey32,
clientKey: ctx.clientKey32,
blockchain: createBlockchainState({
blockchainName,
blockchainPublicKey: ctx.blockchainKey32,
paidLimitBytes: startBonusLimit,
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash: zeroHash32,
lastBlockSignature: lastBlockSig64,
arweaveTxId: '',
}),
isServer,
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
accessServers,
sessionsMode: SESSIONS_MODE_MIXED,
sessions: [],
trustedCount: 0,
});
const unsignedRecord = serializeUnsignedRecordFromState(initialState);
const unsignedHash = await sha256Bytes(unsignedRecord);
const rootSig64 = await signBytes(ctx.rootPrivKey, unsignedHash);
let promoSellerPda = null;
let promoEd25519Ix = null;
let promoSellerLogin = '';
if (cleanPromoCode) {
const dashPos = cleanPromoCode.indexOf('-');
if (!cleanPromoCode.startsWith('1') || dashPos <= 1 || dashPos === cleanPromoCode.length - 1) {
throw new Error('Некорректный формат промокода');
}
const sellerLogin = normalizeLogin(cleanPromoCode.slice(1, dashPos));
const signatureBase58 = cleanPromoCode.slice(dashPos + 1);
promoSellerLogin = sellerLogin;
const [promoSellerAddress] = ctx.solana.PublicKey.findProgramAddressSync(
[new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(sellerLogin)],
ctx.usersProgram,
);
promoSellerPda = promoSellerAddress;
const promoMessage = buildPromoMessageBytes(cleanLogin);
const signatureBytes = base58ToBytes(signatureBase58);
if (signatureBytes.length !== 64) {
throw new Error('Подпись промокода должна быть 64 байта в Base58');
}
const sellerAccount = await ctx.connection.getAccountInfo(promoSellerAddress, 'confirmed');
if (!sellerAccount?.data || sellerAccount.data.length < 42) {
throw new Error('Promo seller PDA не найдена или повреждена');
}
const signerPubkey = new Uint8Array(sellerAccount.data.slice(10, 42));
promoEd25519Ix = new ctx.solana.TransactionInstruction({
programId: ctx.ed25519Program,
keys: [],
data: buildEd25519IxData(signatureBytes, signerPubkey, promoMessage),
});
}
const ixData = serializeCreateUserPdaArgs({
login: cleanLogin,
recoveryKey32: ctx.recoveryKey32,
rootKey32: ctx.rootKey32,
createdAtMs,
additionalLimitBytes: 0n,
clientKey32: ctx.clientKey32,
blockchainPublicKey32: ctx.blockchainKey32,
blockchainName,
usedBytes: 0n,
lastBlockNumber: 0,
lastBlockHash32: zeroHash32,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: '',
isServer,
addressFormatType: isServer ? addressFormatType : 0,
addressFormatVersion: isServer ? addressFormatVersion : 0,
serverAddress: isServer ? serverAddress : '',
syncServers: isServer ? syncServers : [],
accessServers,
sessionsMode: SESSIONS_MODE_MIXED,
sessions: [],
trustedCount: 0,
rootSignature64: rootSig64,
promoSellerLogin,
});
const ed25519RootIx = new ctx.solana.TransactionInstruction({
programId: ctx.ed25519Program,
keys: [],
data: buildEd25519IxData(rootSig64, ctx.rootKey32, unsignedHash),
});
const ed25519BchIx = new ctx.solana.TransactionInstruction({
programId: ctx.ed25519Program,
keys: [],
data: buildEd25519IxData(lastBlockSig64, ctx.blockchainKey32, lastBlockStateHash),
});
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,
});
let signature;
try {
const tx = new ctx.solana.Transaction();
if (promoEd25519Ix) tx.add(promoEd25519Ix);
tx.add(ed25519RootIx, ed25519BchIx, createIx);
signature = await ctx.solana.sendAndConfirmTransaction(
ctx.connection,
tx,
[ctx.clientKeypair],
{ commitment: 'confirmed' },
);
} catch (error) {
throw await attachSolanaLogs(error, ctx.connection);
}
return {
signature,
userPda: ctx.userPda.toBase58(),
pdaAddress: ctx.userPda.toBase58(),
blockchainName,
};
}
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [], promoCode = '' }) {
return createShineUserPdaOnSolana({
login,
keyBundle,
solanaEndpoint,
isServer: false,
accessServers: Array.isArray(accessServers) ? accessServers : [],
promoCode,
});
}
export async function registerServerOnSolana({
login,
keyBundle,
serverAddress,
addressFormatType = 1,
addressFormatVersion = 0,
syncServers = [],
accessServers = [],
solanaEndpoint,
}) {
return createShineUserPdaOnSolana({
login,
keyBundle,
solanaEndpoint,
isServer: true,
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
accessServers,
});
}
export async function updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64,
clientPrivatePkcs8B64,
blockchainPrivatePkcs8B64,
additionalLimitBytes = 0n,
nextUsedBytes,
nextLastBlockNumber,
nextLastBlockHashHex,
serverProfile,
accessServers,
trustedCount,
}) {
const current = await readShineUserPda({ login, solanaEndpoint });
const cleanLogin = current.login;
const endpoint = current.endpoint;
const currentBch = current.blockchain;
const addLimit = BigInt(additionalLimitBytes || 0);
if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит');
if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash;
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
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(SHINE_USERS_USER_PDA_SEED_PREFIX), 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 clientSeed32 = extractSeed32FromPkcs8B64(clientPrivatePkcs8B64);
const clientKeypair = solana.Keypair.fromSeed(clientSeed32);
const lastBlockStateBytes = buildLastBlockStateBytes(
cleanLogin,
currentBch.blockchainName,
effectiveLastNum,
effectiveLastHash,
effectiveUsed,
);
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
const blockchainStateWillChange = (
nextUsedBytes != null
|| nextLastBlockNumber != null
|| nextLastBlockHashHex != null
);
let lastBlockSig64 = currentBch.lastBlockSignature;
if (blockchainPrivatePkcs8B64) {
const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64);
lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash);
} else if (blockchainStateWillChange) {
throw new Error('Для изменения last block state нужен blockchain-ключ');
}
const updatedAtMs = BigInt(Date.now());
const newPaid = currentBch.paidLimitBytes + addLimit;
const newRecordNumber = current.recordNumber + 1;
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
const nextServerProfile = serverProfile
? {
addressFormatType: Number(serverProfile.addressFormatType ?? current.addressFormatType ?? 0),
addressFormatVersion: Number(serverProfile.addressFormatVersion ?? current.addressFormatVersion ?? 0),
serverAddress: String(serverProfile.serverAddress ?? current.serverAddress ?? ''),
syncServers: Array.isArray(serverProfile.syncServers) ? [...serverProfile.syncServers] : [...current.syncServers],
}
: current.serverProfile;
const nextState = createPdaState({
login: cleanLogin,
createdAtMs: current.createdAtMs,
updatedAtMs,
recordNumber: newRecordNumber,
prevRecordHash: prevHash,
recoveryKey: current.recoveryKey,
rootKey: current.rootKey,
clientKey: current.clientKey ?? current.clientKey,
blockchain: createBlockchainState({
blockchainName: currentBch.blockchainName,
blockchainPublicKey: currentBch.blockchainPublicKey,
paidLimitBytes: newPaid,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash: effectiveLastHash,
lastBlockSignature: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
}),
isServer: Boolean(nextServerProfile),
addressFormatType: nextServerProfile?.addressFormatType ?? 0,
addressFormatVersion: nextServerProfile?.addressFormatVersion ?? 0,
serverAddress: nextServerProfile?.serverAddress ?? '',
syncServers: nextServerProfile?.syncServers ?? [],
accessServers: accessServers == null ? current.accessServers : accessServers,
sessionsMode: current.sessionsMode,
sessions: current.sessions,
trustedCount: trustedCount == null ? current.trustedCount : trustedCount,
});
const unsignedNext = serializeUnsignedRecordFromState(nextState);
const unsignedNextHash = await sha256Bytes(unsignedNext);
const rootSig64 = await signBytes(rootPriv, unsignedNextHash);
const ixData = serializeUpdateUserPdaArgs({
login: cleanLogin,
recoveryKey32: current.recoveryKey,
rootKey32: current.rootKey,
createdAtMs: current.createdAtMs,
updatedAtMs,
version: newRecordNumber,
prevHash32: prevHash,
additionalLimitBytes: addLimit,
clientKey32: current.clientKey,
blockchainPublicKey32: currentBch.blockchainPublicKey,
blockchainName: currentBch.blockchainName,
usedBytes: effectiveUsed,
lastBlockNumber: effectiveLastNum,
lastBlockHash32: effectiveLastHash,
lastBlockSignature64: lastBlockSig64,
arweaveTxId: currentBch.arweaveTxId,
isServer: nextState.isServer,
addressFormatType: nextState.addressFormatType,
addressFormatVersion: nextState.addressFormatVersion,
serverAddress: nextState.serverAddress,
syncServers: nextState.syncServers,
accessServers: nextState.accessServers,
sessionsMode: nextState.sessionsMode,
sessions: nextState.sessions,
trustedCount: nextState.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 updateIx = new solana.TransactionInstruction({
programId: usersProgram,
keys: [
{ pubkey: clientKeypair.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 computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 });
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 262_144 });
let signature;
try {
signature = await solana.sendAndConfirmTransaction(
connection,
new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updateIx),
[clientKeypair],
{ commitment: 'confirmed' },
);
} catch (error) {
throw await attachSolanaLogs(error, connection);
}
return {
signature,
userPda: userPda.toBase58(),
pdaAddress: 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 async function updateServerOnSolana({
login,
keyBundle,
serverAddress,
addressFormatType,
addressFormatVersion,
syncServers,
solanaEndpoint,
}) {
return updateShineUserPdaOnSolana({
login,
solanaEndpoint,
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
serverProfile: {
addressFormatType,
addressFormatVersion,
serverAddress,
syncServers,
},
});
}
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;
}