711 lines
29 KiB
JavaScript
711 lines
29 KiB
JavaScript
// Логика управления серверной PDA в Solana (shine_users)
|
||
// Автономный модуль для панели администратора сервера SHiNE
|
||
|
||
const SHINE_USERS_PROGRAM_ID = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
|
||
const SHINE_PAYMENTS_PROGRAM_ID = 'm48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR';
|
||
const SHINE_LOGIN_GUARD_PROGRAM_ID = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo';
|
||
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
|
||
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
|
||
|
||
// Discriminator create_user_pda (sha256("global:create_user_pda")[0..8])
|
||
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
|
||
|
||
let _solanaLib = null;
|
||
async function loadSolanaLib() {
|
||
if (!_solanaLib) _solanaLib = await import('https://esm.sh/@solana/web3.js@1.98.4?bundle');
|
||
return _solanaLib;
|
||
}
|
||
|
||
let _argon2Lib = null;
|
||
async function loadArgon2() {
|
||
if (!_argon2Lib) _argon2Lib = await import('https://esm.sh/@noble/hashes@1.8.0/argon2.js');
|
||
return _argon2Lib;
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Crypto (WebCrypto, Ed25519)
|
||
// -------------------------------------------------------------------
|
||
|
||
async function sha256Bytes(bytes) {
|
||
const buf = await crypto.subtle.digest('SHA-256', bytes);
|
||
return new Uint8Array(buf);
|
||
}
|
||
|
||
async function signEd25519(pkcs8B64, messageBytes) {
|
||
const pkcs8 = Uint8Array.from(atob(pkcs8B64), c => c.charCodeAt(0));
|
||
const key = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
|
||
const sig = await crypto.subtle.sign({ name: 'Ed25519' }, key, messageBytes);
|
||
return new Uint8Array(sig);
|
||
}
|
||
|
||
function base64ToBytes(b64) {
|
||
return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||
}
|
||
|
||
function extractSeed32FromPkcs8B64(pkcs8B64) {
|
||
// Ed25519 PKCS8 (48 байт): seed расположен начиная с байта 16
|
||
return base64ToBytes(pkcs8B64).slice(16, 48);
|
||
}
|
||
|
||
async function anchorDiscriminator(name) {
|
||
const hash = await sha256Bytes(new TextEncoder().encode(`global:${name}`));
|
||
return hash.slice(0, 8);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Borsh-кодирование (Anchor-совместимое)
|
||
// -------------------------------------------------------------------
|
||
|
||
function pushU32LE(buf, v) {
|
||
const n = v >>> 0;
|
||
buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF);
|
||
}
|
||
|
||
function pushU64LE(buf, bigV) {
|
||
const b = typeof bigV === 'bigint' ? bigV : BigInt(bigV);
|
||
const lo = Number(b & 0xFFFFFFFFn) >>> 0;
|
||
const hi = Number((b >> 32n) & 0xFFFFFFFFn) >>> 0;
|
||
pushU32LE(buf, lo);
|
||
pushU32LE(buf, hi);
|
||
}
|
||
|
||
class BorshBuf {
|
||
constructor() { this._b = []; }
|
||
u8(v) { this._b.push(v & 0xFF); }
|
||
u32(v) { pushU32LE(this._b, v); }
|
||
u64(v) { pushU64LE(this._b, v); }
|
||
bool(v) { this.u8(v ? 1 : 0); }
|
||
bytes32(b) { for (const x of b) this._b.push(x); }
|
||
vecU8(b) { this.u32(b.length); for (const x of b) this._b.push(x); }
|
||
str(s) {
|
||
const enc = new TextEncoder().encode(s);
|
||
this.u32(enc.length);
|
||
for (const x of enc) this._b.push(x);
|
||
}
|
||
vecStr(arr) {
|
||
this.u32(arr.length);
|
||
for (const s of arr) this.str(s);
|
||
}
|
||
raw(bytes) { for (const x of bytes) this._b.push(x); }
|
||
result() { return new Uint8Array(this._b); }
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Построение бинарного формата PDA (matches Rust serialize_unsigned_record)
|
||
// -------------------------------------------------------------------
|
||
|
||
function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) {
|
||
const enc = new TextEncoder();
|
||
const buf = [];
|
||
for (const x of enc.encode('SHiNE_LAST_BLOCK')) buf.push(x);
|
||
const loginB = enc.encode(login);
|
||
buf.push(loginB.length); for (const x of loginB) buf.push(x);
|
||
const bchB = enc.encode(blockchainName);
|
||
buf.push(bchB.length); for (const x of bchB) buf.push(x);
|
||
pushU32LE(buf, lastBlockNumber);
|
||
for (const x of lastBlockHash32) buf.push(x);
|
||
pushU64LE(buf, usedBytes);
|
||
return new Uint8Array(buf);
|
||
}
|
||
|
||
function buildUnsignedRecordBytesServer({
|
||
login, createdAtMs, updatedAtMs, recordNumber, prevHash32,
|
||
rootKey32, deviceKey32, blockchainKey32, blockchainName,
|
||
paidLimitBytes, usedBytes, lastBlockNumber, lastBlockHash32, lastBlockSig64, arweaveTxId,
|
||
serverAddress, addressFormatType, addressFormatVersion, syncServers, accessServers, trustedCount,
|
||
}) {
|
||
const enc = new TextEncoder();
|
||
const loginB = enc.encode(login);
|
||
const bchB = enc.encode(blockchainName);
|
||
const buf = [];
|
||
|
||
// Заголовок: MAGIC(5) + FORMAT_MAJOR(1) + FORMAT_MINOR(1) + record_len_placeholder(2)
|
||
buf.push(0x53, 0x48, 0x69, 0x4E, 0x45, 1, 0, 0, 0); // байты 0..8
|
||
|
||
pushU64LE(buf, createdAtMs);
|
||
pushU64LE(buf, updatedAtMs);
|
||
pushU32LE(buf, recordNumber);
|
||
for (const x of prevHash32) buf.push(x);
|
||
|
||
buf.push(loginB.length);
|
||
for (const x of loginB) buf.push(x);
|
||
|
||
buf.push(6); // blocks_count = 6 (сервер)
|
||
|
||
// RootKeyBlock (type=1, ver=0)
|
||
buf.push(1, 0);
|
||
for (const x of rootKey32) buf.push(x);
|
||
|
||
// DeviceKeyBlock (type=2, ver=0)
|
||
buf.push(2, 0);
|
||
for (const x of deviceKey32) buf.push(x);
|
||
|
||
// BlockchainRegistryBlock (type=3, ver=0, count=1, blockchain_type=1)
|
||
buf.push(3, 0, 1, 1);
|
||
buf.push(bchB.length); for (const x of bchB) buf.push(x);
|
||
for (const x of blockchainKey32) buf.push(x);
|
||
pushU64LE(buf, paidLimitBytes);
|
||
pushU64LE(buf, usedBytes);
|
||
pushU32LE(buf, lastBlockNumber);
|
||
for (const x of lastBlockHash32) buf.push(x);
|
||
for (const x of lastBlockSig64) buf.push(x);
|
||
if (arweaveTxId) {
|
||
buf.push(1);
|
||
const aTxB = enc.encode(arweaveTxId);
|
||
buf.push(aTxB.length); for (const x of aTxB) buf.push(x);
|
||
} else {
|
||
buf.push(0);
|
||
}
|
||
|
||
// ServerProfileBlock (type=30, ver=0)
|
||
buf.push(30, 0);
|
||
buf.push(1); // is_server = 1
|
||
buf.push(addressFormatType & 0xFF);
|
||
buf.push(addressFormatVersion & 0xFF);
|
||
const srvB = enc.encode(serverAddress);
|
||
buf.push(srvB.length); for (const x of srvB) buf.push(x);
|
||
buf.push(syncServers.length);
|
||
for (const srv of syncServers) {
|
||
const sB = enc.encode(srv);
|
||
buf.push(sB.length); for (const x of sB) buf.push(x);
|
||
}
|
||
|
||
// AccessServersBlock (type=40, ver=0)
|
||
buf.push(40, 0, accessServers.length);
|
||
for (const srv of accessServers) {
|
||
const sB = enc.encode(srv);
|
||
buf.push(sB.length); for (const x of sB) buf.push(x);
|
||
}
|
||
|
||
// TrustedStateBlock (type=50, ver=0)
|
||
buf.push(50, 0, trustedCount & 0xFF);
|
||
|
||
// Записываем record_len: (длина буфера + 64 байта подписи)
|
||
const recLen = buf.length + 64;
|
||
buf[7] = recLen & 0xFF;
|
||
buf[8] = (recLen >> 8) & 0xFF;
|
||
|
||
return new Uint8Array(buf);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Borsh-сериализация Anchor-инструкций
|
||
// -------------------------------------------------------------------
|
||
|
||
function serializeCreateServerPdaArgs({
|
||
login, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
|
||
blockchainName, usedBytes, lastBlockNumber, lastBlockHash32,
|
||
lastBlockSig64, arweaveTxId, serverAddress, addressFormatType,
|
||
addressFormatVersion, syncServers, accessServers, trustedCount, rootSig64,
|
||
}) {
|
||
const b = new BorshBuf();
|
||
b.raw(CREATE_USER_PDA_DISCRIMINATOR);
|
||
b.str(login);
|
||
b.bytes32(rootKey32);
|
||
b.u64(createdAtMs);
|
||
b.u64(0n); // additional_limit
|
||
// UserMutableFields:
|
||
b.bytes32(deviceKey32);
|
||
b.bytes32(blockchainKey32);
|
||
b.str(blockchainName);
|
||
b.u64(usedBytes);
|
||
b.u32(lastBlockNumber);
|
||
b.vecU8(lastBlockHash32);
|
||
b.vecU8(lastBlockSig64);
|
||
b.str(arweaveTxId);
|
||
b.bool(true); // is_server
|
||
b.u8(addressFormatType);
|
||
b.u8(addressFormatVersion);
|
||
b.str(serverAddress);
|
||
b.vecStr(syncServers);
|
||
b.vecStr(accessServers);
|
||
b.u8(trustedCount);
|
||
b.vecU8(rootSig64);
|
||
return b.result();
|
||
}
|
||
|
||
async function serializeUpdateServerPdaArgs({
|
||
login, rootKey32, createdAtMs, updatedAtMs, version, prevHash32,
|
||
deviceKey32, blockchainKey32, blockchainName, usedBytes,
|
||
lastBlockNumber, lastBlockHash32, lastBlockSig64, arweaveTxId,
|
||
serverAddress, addressFormatType, addressFormatVersion,
|
||
syncServers, accessServers, trustedCount, rootSig64,
|
||
}) {
|
||
const discriminator = await anchorDiscriminator('update_user_pda');
|
||
const b = new BorshBuf();
|
||
b.raw(discriminator);
|
||
b.str(login);
|
||
b.bytes32(rootKey32);
|
||
b.u64(createdAtMs);
|
||
b.u64(updatedAtMs);
|
||
b.u32(version);
|
||
b.vecU8(prevHash32);
|
||
b.u64(0n); // additional_limit
|
||
// UserMutableFields:
|
||
b.bytes32(deviceKey32);
|
||
b.bytes32(blockchainKey32);
|
||
b.str(blockchainName);
|
||
b.u64(usedBytes);
|
||
b.u32(lastBlockNumber);
|
||
b.vecU8(lastBlockHash32);
|
||
b.vecU8(lastBlockSig64);
|
||
b.str(arweaveTxId);
|
||
b.bool(true); // is_server
|
||
b.u8(addressFormatType);
|
||
b.u8(addressFormatVersion);
|
||
b.str(serverAddress);
|
||
b.vecStr(syncServers);
|
||
b.vecStr(accessServers);
|
||
b.u8(trustedCount);
|
||
b.vecU8(rootSig64);
|
||
return b.result();
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Построитель Ed25519-инструкции Solana
|
||
// -------------------------------------------------------------------
|
||
|
||
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
|
||
const sigOff = 16, pkOff = 80, msgOff = 112;
|
||
const data = new Uint8Array(msgOff + 32);
|
||
const v = new DataView(data.buffer);
|
||
data[0] = 1; data[1] = 0;
|
||
v.setUint16(2, sigOff, true); v.setUint16(4, 0xFFFF, true);
|
||
v.setUint16(6, pkOff, true); v.setUint16(8, 0xFFFF, true);
|
||
v.setUint16(10, msgOff, true); v.setUint16(12, 32, true); v.setUint16(14, 0xFFFF, true);
|
||
data.set(sig64, sigOff);
|
||
data.set(pubkey32, pkOff);
|
||
data.set(msgHash32, msgOff);
|
||
return data;
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Парсер бинарных данных PDA (matches Rust deserialize_record_from_pda)
|
||
// -------------------------------------------------------------------
|
||
|
||
export function parsePdaData(raw) {
|
||
const d = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
||
if (d.length < 9) throw new Error('PDA слишком короткая');
|
||
if (String.fromCharCode(d[0], d[1], d[2], d[3], d[4]) !== 'SHiNE') {
|
||
throw new Error('Неверный magic в PDA');
|
||
}
|
||
|
||
const view = new DataView(d.buffer, d.byteOffset);
|
||
const recordLen = view.getUint16(7, true);
|
||
if (recordLen < 9 + 64 || recordLen > d.length) throw new Error('Неверный record_len');
|
||
|
||
// Подписанная часть = байты [0 .. recordLen-64)
|
||
const unsignedBytes = d.slice(0, recordLen - 64);
|
||
|
||
let cur = 9;
|
||
const ru8 = () => d[cur++];
|
||
const ru32 = () => { const v = view.getUint32(cur, true); cur += 4; return v; };
|
||
const ru64 = () => { const v = view.getBigUint64(cur, true); cur += 8; return v; };
|
||
const rBytes = n => { const s = d.slice(cur, cur + n); cur += n; return s; };
|
||
const rStr = () => { const len = ru8(); return new TextDecoder().decode(rBytes(len)); };
|
||
|
||
const createdAtMs = ru64();
|
||
const updatedAtMs = ru64();
|
||
const recordNumber = ru32();
|
||
const prevRecordHash = rBytes(32);
|
||
const login = rStr();
|
||
const blocksCount = ru8();
|
||
|
||
let rootKey32 = null, deviceKey32 = null, blockchainData = null;
|
||
let isServer = false, serverData = null;
|
||
let accessServers = [], trustedCount = 0;
|
||
|
||
for (let i = 0; i < blocksCount; i++) {
|
||
const blockType = ru8();
|
||
ru8(); // block_version
|
||
|
||
if (blockType === 1) {
|
||
rootKey32 = rBytes(32);
|
||
} else if (blockType === 2) {
|
||
deviceKey32 = rBytes(32);
|
||
} else if (blockType === 3) {
|
||
const count = ru8();
|
||
const blockchainType = ru8();
|
||
const blockchainName = rStr();
|
||
const blockchainPublicKey = rBytes(32);
|
||
const paidLimitBytes = ru64();
|
||
const usedBytes = ru64();
|
||
const lastBlockNumber = ru32();
|
||
const lastBlockHash = rBytes(32);
|
||
const lastBlockSignature = rBytes(64);
|
||
const arweavePresent = ru8();
|
||
const arweaveTxId = arweavePresent === 1 ? rStr() : '';
|
||
blockchainData = {
|
||
blockchainType, blockchainName, blockchainPublicKey,
|
||
paidLimitBytes, usedBytes, lastBlockNumber,
|
||
lastBlockHash, lastBlockSignature, arweaveTxId,
|
||
};
|
||
} else if (blockType === 30) {
|
||
if (ru8() === 1) {
|
||
isServer = true;
|
||
const addressFormatType = ru8();
|
||
const addressFormatVersion = ru8();
|
||
const serverAddress = rStr();
|
||
const syncCount = ru8();
|
||
const syncServers = [];
|
||
for (let j = 0; j < syncCount; j++) syncServers.push(rStr());
|
||
serverData = { addressFormatType, addressFormatVersion, serverAddress, syncServers };
|
||
}
|
||
} else if (blockType === 40) {
|
||
const cnt = ru8();
|
||
for (let j = 0; j < cnt; j++) accessServers.push(rStr());
|
||
} else if (blockType === 50) {
|
||
trustedCount = ru8();
|
||
}
|
||
}
|
||
|
||
const signature = d.slice(cur, cur + 64);
|
||
|
||
return {
|
||
recordLen, unsignedBytes,
|
||
createdAtMs, updatedAtMs, recordNumber, prevRecordHash,
|
||
login, rootKey32, deviceKey32, blockchainData,
|
||
isServer, serverData, accessServers, trustedCount, signature,
|
||
};
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Вспомогательная: читает start_bonus_limit из economy config PDA
|
||
// -------------------------------------------------------------------
|
||
|
||
function readStartBonusLimit(data) {
|
||
// Borsh: version(u8=1) + reg_fee(u64) + lamports_per_step(u64) = 17 байт до start_bonus_limit
|
||
return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigUint64(17, true);
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Читает и парсит существующую PDA с блокчейна
|
||
// -------------------------------------------------------------------
|
||
|
||
export async function readServerPdaData({ login, solanaEndpoint }) {
|
||
const solana = await loadSolanaLib();
|
||
const connection = new solana.Connection(String(solanaEndpoint), 'confirmed');
|
||
const loginNorm = String(login).trim().toLowerCase();
|
||
const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID);
|
||
const enc = new TextEncoder();
|
||
const [userPda] = solana.PublicKey.findProgramAddressSync(
|
||
[enc.encode('login='), enc.encode(loginNorm)],
|
||
usersProgram,
|
||
);
|
||
const ai = await connection.getAccountInfo(userPda, 'confirmed');
|
||
if (!ai) throw new Error(`PDA не найдена для логина «${loginNorm}»`);
|
||
const parsed = parsePdaData(ai.data);
|
||
parsed.pdaAddress = userPda.toBase58();
|
||
return parsed;
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Регистрация нового серверного аккаунта в Solana
|
||
// -------------------------------------------------------------------
|
||
|
||
export async function registerServerOnSolana({
|
||
login, keyBundle, serverAddress,
|
||
addressFormatType = 1, addressFormatVersion = 0,
|
||
syncServers = [], accessServers = [],
|
||
solanaEndpoint,
|
||
}) {
|
||
const solana = await loadSolanaLib();
|
||
const connection = new solana.Connection(String(solanaEndpoint), '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 loginNorm = String(login).trim().toLowerCase();
|
||
const blockchainName = `${loginNorm}-001`;
|
||
const zeroHash32 = new Uint8Array(32);
|
||
|
||
const [userPda] = solana.PublicKey.findProgramAddressSync(
|
||
[enc.encode('login='), enc.encode(loginNorm)], usersProgram);
|
||
const [economyConfigPda] = solana.PublicKey.findProgramAddressSync(
|
||
[enc.encode('shine_users_economy_config')], usersProgram);
|
||
const [inflowVault] = solana.PublicKey.findProgramAddressSync(
|
||
[enc.encode('shine_payments_inflow_vault')], paymentsProgram);
|
||
|
||
const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
||
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
|
||
const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64);
|
||
const deviceKeypair = solana.Keypair.fromSeed(
|
||
extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64));
|
||
|
||
const ecoAccount = await connection.getAccountInfo(economyConfigPda);
|
||
if (!ecoAccount) throw new Error('Economy config не инициализирован');
|
||
const paidLimitBytes = readStartBonusLimit(ecoAccount.data); // additional_limit = 0
|
||
const createdAtMs = BigInt(Date.now());
|
||
|
||
// Подписываем LastBlockState ключом блокчейна (начальное состояние: всё нули)
|
||
const lbsBytes = buildLastBlockStateBytes(loginNorm, blockchainName, 0, zeroHash32, 0n);
|
||
const lbsHash = await sha256Bytes(lbsBytes);
|
||
const lastBlockSig64 = await signEd25519(keyBundle.blockchainPair.privatePkcs8B64, lbsHash);
|
||
|
||
// Строим и подписываем беззнаковую запись PDA корневым ключом
|
||
const unsignedRecord = buildUnsignedRecordBytesServer({
|
||
login: loginNorm, createdAtMs, updatedAtMs: createdAtMs,
|
||
recordNumber: 0, prevHash32: zeroHash32,
|
||
rootKey32, deviceKey32, blockchainKey32, blockchainName,
|
||
paidLimitBytes, usedBytes: 0n, lastBlockNumber: 0,
|
||
lastBlockHash32: zeroHash32, lastBlockSig64, arweaveTxId: '',
|
||
serverAddress, addressFormatType, addressFormatVersion,
|
||
syncServers, accessServers, trustedCount: 0,
|
||
});
|
||
const unsignedHash = await sha256Bytes(unsignedRecord);
|
||
const rootSig64 = await signEd25519(keyBundle.rootPair.privatePkcs8B64, unsignedHash);
|
||
|
||
const ixData = serializeCreateServerPdaArgs({
|
||
login: loginNorm, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
|
||
blockchainName, usedBytes: 0n, lastBlockNumber: 0,
|
||
lastBlockHash32: zeroHash32, lastBlockSig64, arweaveTxId: '',
|
||
serverAddress, addressFormatType, addressFormatVersion,
|
||
syncServers, accessServers, trustedCount: 0, rootSig64,
|
||
});
|
||
|
||
const tx = new solana.Transaction().add(
|
||
new solana.TransactionInstruction({
|
||
programId: ed25519Program, keys: [],
|
||
data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash),
|
||
}),
|
||
new solana.TransactionInstruction({
|
||
programId: ed25519Program, keys: [],
|
||
data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash),
|
||
}),
|
||
new solana.TransactionInstruction({
|
||
programId: usersProgram,
|
||
keys: [
|
||
{ pubkey: deviceKeypair.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: economyConfigPda, isSigner: false, isWritable: false },
|
||
{ pubkey: loginGuardProgram, isSigner: false, isWritable: false },
|
||
],
|
||
data: ixData,
|
||
}),
|
||
);
|
||
|
||
const signature = await solana.sendAndConfirmTransaction(
|
||
connection, tx, [deviceKeypair], { commitment: 'confirmed' });
|
||
|
||
return { signature, pdaAddress: userPda.toBase58(), blockchainName };
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Обновление серверного профиля в существующей PDA
|
||
// Для обновления нужен только root-ключ (подпись записи) + device-ключ (оплата).
|
||
// Blockchain-ключ не нужен — переиспользуем существующую подпись LastBlockState из PDA.
|
||
// -------------------------------------------------------------------
|
||
|
||
export async function updateServerOnSolana({
|
||
login, keyBundle, serverAddress,
|
||
addressFormatType, addressFormatVersion,
|
||
syncServers,
|
||
solanaEndpoint,
|
||
}) {
|
||
const solana = await loadSolanaLib();
|
||
const connection = new solana.Connection(String(solanaEndpoint), '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 loginNorm = String(login).trim().toLowerCase();
|
||
|
||
const [userPda] = solana.PublicKey.findProgramAddressSync(
|
||
[enc.encode('login='), enc.encode(loginNorm)], usersProgram);
|
||
const [economyConfigPda] = solana.PublicKey.findProgramAddressSync(
|
||
[enc.encode('shine_users_economy_config')], usersProgram);
|
||
const [inflowVault] = solana.PublicKey.findProgramAddressSync(
|
||
[enc.encode('shine_payments_inflow_vault')], paymentsProgram);
|
||
|
||
// Читаем существующую PDA
|
||
const ai = await connection.getAccountInfo(userPda, 'confirmed');
|
||
if (!ai) throw new Error(`PDA не найдена для логина «${loginNorm}»`);
|
||
const pda = parsePdaData(ai.data);
|
||
if (!pda.isServer) throw new Error('Эта PDA не является серверной (is_server = false)');
|
||
|
||
const bch = pda.blockchainData;
|
||
const deviceKeypair = solana.Keypair.fromSeed(
|
||
extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64));
|
||
|
||
// Формат адреса: берём из аргументов или из существующей PDA
|
||
const fmtType = addressFormatType ?? pda.serverData?.addressFormatType ?? 1;
|
||
const fmtVersion = addressFormatVersion ?? pda.serverData?.addressFormatVersion ?? 0;
|
||
|
||
// prev_hash = sha256(unsigned_bytes предыдущей записи)
|
||
const prevHash32 = await sha256Bytes(pda.unsignedBytes);
|
||
const updatedAtMs = BigInt(Date.now());
|
||
const newVersion = pda.recordNumber + 1;
|
||
|
||
// Строим новую беззнаковую запись
|
||
const unsignedRecord = buildUnsignedRecordBytesServer({
|
||
login: loginNorm,
|
||
createdAtMs: pda.createdAtMs, updatedAtMs,
|
||
recordNumber: newVersion, prevHash32,
|
||
rootKey32: pda.rootKey32, deviceKey32: pda.deviceKey32,
|
||
blockchainKey32: bch.blockchainPublicKey, blockchainName: bch.blockchainName,
|
||
paidLimitBytes: bch.paidLimitBytes, usedBytes: bch.usedBytes,
|
||
lastBlockNumber: bch.lastBlockNumber,
|
||
lastBlockHash32: bch.lastBlockHash, lastBlockSig64: bch.lastBlockSignature,
|
||
arweaveTxId: bch.arweaveTxId,
|
||
serverAddress, addressFormatType: fmtType, addressFormatVersion: fmtVersion,
|
||
syncServers, accessServers: pda.accessServers, trustedCount: pda.trustedCount,
|
||
});
|
||
const unsignedHash = await sha256Bytes(unsignedRecord);
|
||
const rootSig64 = await signEd25519(keyBundle.rootPair.privatePkcs8B64, unsignedHash);
|
||
|
||
// Хэш LastBlockState из существующей PDA (те же данные — та же подпись)
|
||
const lbsBytes = buildLastBlockStateBytes(
|
||
loginNorm, bch.blockchainName,
|
||
bch.lastBlockNumber, bch.lastBlockHash, bch.usedBytes);
|
||
const lbsHash = await sha256Bytes(lbsBytes);
|
||
|
||
const ixData = await serializeUpdateServerPdaArgs({
|
||
login: loginNorm, rootKey32: pda.rootKey32,
|
||
createdAtMs: pda.createdAtMs, updatedAtMs,
|
||
version: newVersion, prevHash32,
|
||
deviceKey32: pda.deviceKey32, blockchainKey32: bch.blockchainPublicKey,
|
||
blockchainName: bch.blockchainName,
|
||
usedBytes: bch.usedBytes, lastBlockNumber: bch.lastBlockNumber,
|
||
lastBlockHash32: bch.lastBlockHash, lastBlockSig64: bch.lastBlockSignature,
|
||
arweaveTxId: bch.arweaveTxId,
|
||
serverAddress, addressFormatType: fmtType, addressFormatVersion: fmtVersion,
|
||
syncServers, accessServers: pda.accessServers, trustedCount: pda.trustedCount,
|
||
rootSig64,
|
||
});
|
||
|
||
const tx = new solana.Transaction().add(
|
||
// Ed25519: подпись новой записи корневым ключом
|
||
new solana.TransactionInstruction({
|
||
programId: ed25519Program, keys: [],
|
||
data: buildEd25519IxData(rootSig64, pda.rootKey32, unsignedHash),
|
||
}),
|
||
// Ed25519: переиспользуем существующую подпись LastBlockState из PDA
|
||
new solana.TransactionInstruction({
|
||
programId: ed25519Program, keys: [],
|
||
data: buildEd25519IxData(bch.lastBlockSignature, bch.blockchainPublicKey, lbsHash),
|
||
}),
|
||
new solana.TransactionInstruction({
|
||
programId: usersProgram,
|
||
keys: [
|
||
{ pubkey: deviceKeypair.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: economyConfigPda, isSigner: false, isWritable: false },
|
||
],
|
||
data: ixData,
|
||
}),
|
||
);
|
||
|
||
const signature = await solana.sendAndConfirmTransaction(
|
||
connection, tx, [deviceKeypair], { commitment: 'confirmed' });
|
||
|
||
return { signature, pdaAddress: userPda.toBase58() };
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Деривация keyBundle из логина + пароля
|
||
// Идентична логике SHiNE-клиента (crypto-utils.js):
|
||
// masterSecret = Argon2id(login+"\n"+password, salt=sha256("shine-auth-v2|login=...|suffix=master.secret"))
|
||
// rootPair = Ed25519(sha256(base64(master) + "|root.key"))
|
||
// blockchainPair = Ed25519(sha256(base64(master) + "|bch.key"))
|
||
// devicePair = Ed25519(sha256(base64(master) + "|dev.key"))
|
||
// -------------------------------------------------------------------
|
||
|
||
function _b64urlToStd(s) {
|
||
const n = s.replace(/-/g, '+').replace(/_/g, '/');
|
||
return n + '='.repeat((4 - n.length % 4) % 4);
|
||
}
|
||
|
||
function _ed25519Pkcs8FromSeed(seed32) {
|
||
const prefix = new Uint8Array([
|
||
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||
]);
|
||
const out = new Uint8Array(prefix.length + 32);
|
||
out.set(prefix); out.set(seed32, prefix.length);
|
||
return out;
|
||
}
|
||
|
||
async function _deriveEd25519PairFromMasterSecret(masterSecret32, suffix) {
|
||
const enc = new TextEncoder();
|
||
const material = `${btoa(String.fromCharCode(...masterSecret32))}|${suffix}`;
|
||
const seed = await sha256Bytes(enc.encode(material));
|
||
const pkcs8 = _ed25519Pkcs8FromSeed(seed);
|
||
const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
||
const jwk = await crypto.subtle.exportKey('jwk', privateKey);
|
||
if (!jwk.x) throw new Error(`Не удалось получить публичный ключ (suffix=${suffix})`);
|
||
const pubBytes = base64ToBytes(_b64urlToStd(jwk.x));
|
||
return {
|
||
publicKeyB64: btoa(String.fromCharCode(...pubBytes)),
|
||
privatePkcs8B64: btoa(String.fromCharCode(...pkcs8)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Выводит полный keyBundle из логина и пароля.
|
||
* Та же самая логика, что используется в SHiNE-клиенте при регистрации.
|
||
*
|
||
* @param {string} login — логин сервера (нормализуется в нижний регистр)
|
||
* @param {string} password — пароль
|
||
* @param {function} [onProgress] — коллбэк(0..1) прогресса Argon2id
|
||
* @returns {{ rootPair, blockchainPair, devicePair }}
|
||
*/
|
||
export async function deriveKeyBundleFromPassword({ login, password, onProgress }) {
|
||
const { argon2idAsync } = await loadArgon2();
|
||
const enc = new TextEncoder();
|
||
const loginNorm = String(login || '').trim().toLowerCase();
|
||
const pwd = String(password ?? '');
|
||
|
||
// Salt для master secret = sha256("shine-auth-v2|login=...|suffix=master.secret")[0..16]
|
||
const saltSource = `shine-auth-v2|login=${loginNorm}|suffix=master.secret`;
|
||
const saltFull = await sha256Bytes(enc.encode(saltSource));
|
||
const salt = saltFull.slice(0, 16);
|
||
|
||
const passBytes = enc.encode(`${loginNorm}\n${pwd}`);
|
||
const masterRaw = await argon2idAsync(passBytes, salt, {
|
||
t: 2, m: 65536, p: 1, dkLen: 32,
|
||
onProgress,
|
||
});
|
||
const masterSecret32 = new Uint8Array(masterRaw);
|
||
|
||
const [rootPair, blockchainPair, devicePair] = await Promise.all([
|
||
_deriveEd25519PairFromMasterSecret(masterSecret32, 'root.key'),
|
||
_deriveEd25519PairFromMasterSecret(masterSecret32, 'bch.key'),
|
||
_deriveEd25519PairFromMasterSecret(masterSecret32, 'dev.key'),
|
||
]);
|
||
|
||
const masterSecretB64 = btoa(String.fromCharCode(...masterSecret32));
|
||
return { masterSecretB64, rootPair, blockchainPair, devicePair };
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// Кодирование байт в base58 (для отображения Solana-адреса)
|
||
// -------------------------------------------------------------------
|
||
|
||
export function base58Encode(bytes) {
|
||
const ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||
let num = 0n;
|
||
for (const b of bytes) num = (num << 8n) | BigInt(b);
|
||
let result = '';
|
||
while (num > 0n) {
|
||
result = ALPHA[Number(num % 58n)] + result;
|
||
num /= 58n;
|
||
}
|
||
for (const b of bytes) {
|
||
if (b !== 0) break;
|
||
result = '1' + result;
|
||
}
|
||
return result;
|
||
}
|