248 lines
9.2 KiB
JavaScript
248 lines
9.2 KiB
JavaScript
import { base64ToBytes } from './crypto-utils.js';
|
||
import { PublicKey } from './vendor/solana-publickey-bundle.js';
|
||
|
||
const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com';
|
||
const SHINE_USERS_PROGRAM_ID = '3bYrnXwLc56oVPUBAjY8zTMLwHCYq29b5rUMu3b64SQJ';
|
||
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
|
||
const DEFAULT_SHINE_SERVER_LOGIN = 'shineupme';
|
||
const DEFAULT_SHINE_SERVER_ADDRESS = 'shineup.me';
|
||
|
||
function normalizeHostLike(value) {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) return '';
|
||
try {
|
||
const withScheme = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||
const parsed = new URL(withScheme);
|
||
return String(parsed.host || '').trim().toLowerCase();
|
||
} catch {
|
||
return raw.replace(/^https?:\/\//i, '').replace(/^wss?:\/\//i, '').replace(/\/.*$/, '').trim().toLowerCase();
|
||
}
|
||
}
|
||
|
||
function normalizeServerLogin(value) {
|
||
return String(value || '').trim().toLowerCase();
|
||
}
|
||
|
||
function buildHttpBase(address) {
|
||
const host = normalizeHostLike(address) || DEFAULT_SHINE_SERVER_ADDRESS;
|
||
return `https://${host}`;
|
||
}
|
||
|
||
function buildWsUrl(address) {
|
||
const host = normalizeHostLike(address) || DEFAULT_SHINE_SERVER_ADDRESS;
|
||
return `wss://${host}/ws`;
|
||
}
|
||
|
||
function readU8(bytes, cursorRef) {
|
||
if (cursorRef.value >= bytes.length) throw new Error('Повреждённый формат PDA');
|
||
return bytes[cursorRef.value++];
|
||
}
|
||
|
||
function readBytes(bytes, cursorRef, length) {
|
||
if (cursorRef.value + length > bytes.length) throw new Error('Повреждённый формат PDA');
|
||
const out = bytes.slice(cursorRef.value, cursorRef.value + length);
|
||
cursorRef.value += length;
|
||
return out;
|
||
}
|
||
|
||
function readStrU8(bytes, cursorRef) {
|
||
const length = readU8(bytes, cursorRef);
|
||
return new TextDecoder().decode(readBytes(bytes, cursorRef, length));
|
||
}
|
||
|
||
function parseServerFieldsFromUserPda(dataBytes) {
|
||
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
|
||
if (bytes.length < 5) throw new Error('Некорректный формат PDA');
|
||
const cursorRef = { value: 0 };
|
||
const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5));
|
||
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
|
||
cursorRef.value += 1; // format_major
|
||
cursorRef.value += 1; // format_minor
|
||
cursorRef.value += 2; // record_len
|
||
cursorRef.value += 8; // created_at_ms
|
||
cursorRef.value += 8; // updated_at_ms
|
||
cursorRef.value += 4; // record_number
|
||
cursorRef.value += 32; // prev_record_hash
|
||
readStrU8(bytes, cursorRef); // login
|
||
const blocksCount = readU8(bytes, cursorRef);
|
||
|
||
let isServer = false;
|
||
let serverAddress = '';
|
||
let accessServers = [];
|
||
let recoveryKey32 = null;
|
||
let rootKey32 = null;
|
||
let clientKey32 = null;
|
||
let blockchainKey32 = null;
|
||
let blockchainName = '';
|
||
let homeserverSessions = [];
|
||
|
||
for (let i = 0; i < blocksCount; i += 1) {
|
||
const blockType = readU8(bytes, cursorRef);
|
||
cursorRef.value += 1; // block_version
|
||
|
||
if (blockType === 0 || blockType === 1 || blockType === 2) {
|
||
const key32 = readBytes(bytes, cursorRef, 32);
|
||
if (blockType === 0) recoveryKey32 = key32;
|
||
if (blockType === 1) rootKey32 = key32;
|
||
if (blockType === 2) clientKey32 = key32;
|
||
continue;
|
||
}
|
||
if (blockType === 3) {
|
||
const count = readU8(bytes, cursorRef);
|
||
for (let j = 0; j < count; j += 1) {
|
||
cursorRef.value += 1;
|
||
const currentBlockchainName = readStrU8(bytes, cursorRef);
|
||
const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32);
|
||
if (!blockchainKey32) {
|
||
blockchainKey32 = currentBlockchainKey32;
|
||
blockchainName = currentBlockchainName;
|
||
}
|
||
cursorRef.value += 8 + 8 + 4 + 32 + 64;
|
||
const arPresent = readU8(bytes, cursorRef);
|
||
if (arPresent === 1) readStrU8(bytes, cursorRef);
|
||
}
|
||
continue;
|
||
}
|
||
if (blockType === 30) {
|
||
isServer = readU8(bytes, cursorRef) === 1;
|
||
if (isServer) {
|
||
cursorRef.value += 1; // address_format_type
|
||
cursorRef.value += 1; // address_format_version
|
||
serverAddress = readStrU8(bytes, cursorRef);
|
||
const syncCount = readU8(bytes, cursorRef);
|
||
for (let j = 0; j < syncCount; j += 1) readStrU8(bytes, cursorRef);
|
||
}
|
||
continue;
|
||
}
|
||
if (blockType === 40) {
|
||
const accessCount = readU8(bytes, cursorRef);
|
||
accessServers = [];
|
||
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
|
||
continue;
|
||
}
|
||
if (blockType === 50) {
|
||
cursorRef.value += 1;
|
||
const sessionsCount = readU8(bytes, cursorRef);
|
||
for (let j = 0; j < sessionsCount; j += 1) {
|
||
const sessionType = readU8(bytes, cursorRef);
|
||
const sessionVersion = readU8(bytes, cursorRef);
|
||
const sessionName = readStrU8(bytes, cursorRef);
|
||
const sessionPubKey32 = readBytes(bytes, cursorRef, 32);
|
||
if (sessionType === 100) {
|
||
homeserverSessions.push({
|
||
sessionType,
|
||
sessionVersion,
|
||
sessionName,
|
||
sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(),
|
||
sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`,
|
||
});
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (blockType === 70) {
|
||
cursorRef.value += 1;
|
||
continue;
|
||
}
|
||
throw new Error(`Неизвестный блок PDA: ${blockType}`);
|
||
}
|
||
|
||
return {
|
||
isServer,
|
||
serverAddress: normalizeHostLike(serverAddress),
|
||
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
|
||
publicKeys: {
|
||
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
|
||
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
|
||
clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
|
||
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
|
||
blockchainName,
|
||
},
|
||
homeserverSessions,
|
||
};
|
||
}
|
||
|
||
async function fetchUserPda(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
|
||
const cleanLogin = normalizeServerLogin(login);
|
||
if (!cleanLogin) throw new Error('Не указан логин для чтения PDA.');
|
||
const usersProgram = new PublicKey(SHINE_USERS_PROGRAM_ID);
|
||
const enc = new TextEncoder();
|
||
const [userPda] = PublicKey.findProgramAddressSync(
|
||
[enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)],
|
||
usersProgram,
|
||
);
|
||
|
||
const response = await fetch(String(solanaEndpoint || SOLANA_ENDPOINT_DEFAULT), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
id: 1,
|
||
method: 'getAccountInfo',
|
||
params: [userPda.toBase58(), { encoding: 'base64', commitment: 'confirmed' }],
|
||
}),
|
||
});
|
||
if (!response.ok) throw new Error('Не удалось прочитать Solana RPC.');
|
||
const json = await response.json();
|
||
const dataB64 = json?.result?.value?.data?.[0];
|
||
if (!dataB64) throw new Error(`PDA не найдена для логина @${cleanLogin}.`);
|
||
return parseServerFieldsFromUserPda(base64ToBytes(dataB64));
|
||
}
|
||
|
||
export async function resolveShineServerByServerLogin(serverLogin, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
|
||
const cleanServerLogin = normalizeServerLogin(serverLogin) || DEFAULT_SHINE_SERVER_LOGIN;
|
||
const parsed = await fetchUserPda(cleanServerLogin, solanaEndpoint);
|
||
if (!parsed.isServer) {
|
||
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер SHiNE.`);
|
||
}
|
||
if (!parsed.serverAddress) {
|
||
throw new Error(`У server PDA пользователя @${cleanServerLogin} не задан server_address.`);
|
||
}
|
||
return {
|
||
serverLogin: cleanServerLogin,
|
||
serverAddress: parsed.serverAddress,
|
||
serverHttp: buildHttpBase(parsed.serverAddress),
|
||
serverUrl: buildWsUrl(parsed.serverAddress),
|
||
};
|
||
}
|
||
|
||
export async function resolveShineServerByUserLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
|
||
const cleanLogin = normalizeServerLogin(login);
|
||
if (!cleanLogin) throw new Error('Не указан логин пользователя.');
|
||
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);
|
||
const serverLogin = normalizeServerLogin(parsed.accessServers?.[0] || '');
|
||
if (!serverLogin) {
|
||
throw new Error(`У пользователя @${cleanLogin} в PDA не найден первый сервер доступа.`);
|
||
}
|
||
const resolved = await resolveShineServerByServerLogin(serverLogin, solanaEndpoint);
|
||
return {
|
||
login: cleanLogin,
|
||
accessServers: parsed.accessServers,
|
||
serverLogin: resolved.serverLogin,
|
||
serverAddress: resolved.serverAddress,
|
||
serverHttp: resolved.serverHttp,
|
||
serverUrl: resolved.serverUrl,
|
||
};
|
||
}
|
||
|
||
export async function readWalletProfileByLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
|
||
const cleanLogin = normalizeServerLogin(login);
|
||
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);
|
||
return {
|
||
login: cleanLogin,
|
||
accessServers: parsed.accessServers,
|
||
publicKeys: parsed.publicKeys,
|
||
homeserverSessions: parsed.homeserverSessions,
|
||
};
|
||
}
|
||
|
||
export {
|
||
DEFAULT_SHINE_SERVER_ADDRESS,
|
||
DEFAULT_SHINE_SERVER_LOGIN,
|
||
SOLANA_ENDPOINT_DEFAULT,
|
||
buildHttpBase,
|
||
buildWsUrl,
|
||
normalizeServerLogin,
|
||
};
|