SHA256
# Conflicts: # VERSION.properties # shine-UI/js/pages/registration-faq-view.js # shine-UI/js/services/shine-user-pda-service.js
795 lines
38 KiB
JavaScript
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)), userPda: pda.toBase58(), 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; }
|