import { importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; import { extractSeed32FromPkcs8B64 } from './device-key-utils.js'; import { SHINE_PAYMENTS_PROGRAM_ID, SHINE_USERS_ECONOMY_CONFIG_SEED, SHINE_USERS_PROGRAM_ID } from '../solana-programs.js'; const MAGIC = 'SHiNE'; const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK'; const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault'; const LIMIT_STEP = 10_000n; const UPDATE_USER_PDA_DISCRIMINATOR = new Uint8Array([42, 133, 114, 232, 38, 245, 167, 234]); const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111'; const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111'; const BLOCK_TYPE_ROOT_KEY = 1; const BLOCK_TYPE_DEVICE_KEY = 2; const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3; const BLOCK_TYPE_SERVER_PROFILE = 30; const BLOCK_TYPE_ACCESS_SERVERS = 40; const BLOCK_TYPE_TRUSTED_STATE = 50; let solanaLibPromise = null; function loadSolanaLib() { if (!solanaLibPromise) solanaLibPromise = import('https://esm.sh/@solana/web3.js@1.98.4?bundle'); return solanaLibPromise; } function pushU32LE(buf, v) { const n = Number(v) >>> 0; buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff); } function pushU64LE(buf, v) { const b = BigInt(v); 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 s of arr) pushStrU32(buf, s); } function makeReader(bytes) { let o = 0; const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const ensure = (n) => { if (o + n > bytes.length) throw new Error('Повреждённый формат PDA'); }; const readU8 = () => { ensure(1); const v = dv.getUint8(o); o += 1; return v; }; const readU16 = () => { ensure(2); const v = dv.getUint16(o, true); o += 2; return v; }; const readU32 = () => { ensure(4); const v = dv.getUint32(o, true); o += 4; return v; }; const readU64 = () => { ensure(8); const v = dv.getBigUint64(o, true); o += 8; return v; }; const readBytes = (n) => { ensure(n); const out = bytes.slice(o, o + n); o += n; return out; }; const readStrU8 = () => { const len = readU8(); return new TextDecoder().decode(readBytes(len)); }; return { readU8, readU16, readU32, readU64, readBytes, readStrU8 }; } function parseShineUserPda(dataBytes) { const r = makeReader(dataBytes); const magic = new TextDecoder().decode(r.readBytes(5)); if (magic !== MAGIC) throw new Error('Некорректный формат PDA'); r.readU8(); r.readU8(); r.readU16(); const createdAtMs = r.readU64(); const updatedAtMs = r.readU64(); const recordNumber = r.readU32(); const prevRecordHash = r.readBytes(32); const login = r.readStrU8(); const blocksCount = r.readU8(); const out = { createdAtMs, updatedAtMs, recordNumber, prevRecordHash, login, rootKey: null, deviceKey: null, blockchain: null, isServer: false, serverKey: new Uint8Array(32), serverAddress: '', syncServers: [], accessServers: [], trustedCount: 0, }; for (let i = 0; i < blocksCount; i += 1) { const type = r.readU8(); r.readU8(); if (type === BLOCK_TYPE_ROOT_KEY) { out.rootKey = r.readBytes(32); continue; } if (type === BLOCK_TYPE_DEVICE_KEY) { out.deviceKey = r.readBytes(32); continue; } if (type === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) { const count = r.readU8(); for (let j = 0; j < count; j += 1) { const blockchainType = r.readU8(); const blockchainName = r.readStrU8(); const blockchainPublicKey = r.readBytes(32); const paidLimitBytes = r.readU64(); const usedBytes = r.readU64(); const lastBlockNumber = r.readU32(); const lastBlockHash = r.readBytes(32); const lastBlockSignature = r.readBytes(64); const arPresent = r.readU8(); const arweaveTxId = arPresent ? r.readStrU8() : ''; if (!out.blockchain) { out.blockchain = { blockchainType, blockchainName, blockchainPublicKey, paidLimitBytes, usedBytes, lastBlockNumber, lastBlockHash, lastBlockSignature, arweaveTxId, }; } } continue; } if (type === BLOCK_TYPE_SERVER_PROFILE) { out.isServer = r.readU8() === 1; out.serverKey = r.readBytes(32); out.serverAddress = r.readStrU8(); const syncCount = r.readU8(); out.syncServers = []; for (let k = 0; k < syncCount; k += 1) out.syncServers.push(r.readStrU8()); continue; } if (type === BLOCK_TYPE_ACCESS_SERVERS) { const accessCount = r.readU8(); out.accessServers = []; for (let k = 0; k < accessCount; k += 1) out.accessServers.push(r.readStrU8()); continue; } if (type === BLOCK_TYPE_TRUSTED_STATE) { out.trustedCount = r.readU8(); continue; } throw new Error(`Неизвестный блок PDA: ${type}`); } if (!out.rootKey || !out.deviceKey || !out.blockchain) { throw new Error('В PDA отсутствуют обязательные блоки'); } return out; } function serializeUnsignedRecordFromState(stateLike) { const buf = []; const login = String(stateLike.login || ''); const bch = stateLike.blockchain; buf.push(0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0); pushU64LE(buf, stateLike.createdAtMs); pushU64LE(buf, stateLike.updatedAtMs); pushU32LE(buf, stateLike.recordNumber); for (const x of stateLike.prevRecordHash) buf.push(x); pushStrU8(buf, login); const blocksCount = stateLike.isServer ? 6 : 5; buf.push(blocksCount); buf.push(BLOCK_TYPE_ROOT_KEY, 0); for (const x of stateLike.rootKey) buf.push(x); buf.push(BLOCK_TYPE_DEVICE_KEY, 0); for (const x of stateLike.deviceKey) buf.push(x); buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1); buf.push(bch.blockchainType); pushStrU8(buf, bch.blockchainName); for (const x of bch.blockchainPublicKey) buf.push(x); pushU64LE(buf, bch.paidLimitBytes); pushU64LE(buf, bch.usedBytes); pushU32LE(buf, bch.lastBlockNumber); for (const x of bch.lastBlockHash) buf.push(x); for (const x of bch.lastBlockSignature) buf.push(x); if (String(bch.arweaveTxId || '').trim()) { buf.push(1); pushStrU8(buf, bch.arweaveTxId); } else { buf.push(0); } if (stateLike.isServer) { buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1); for (const x of stateLike.serverKey) buf.push(x); pushStrU8(buf, stateLike.serverAddress); const sync = Array.isArray(stateLike.syncServers) ? stateLike.syncServers : []; buf.push(sync.length & 0xff); for (const s of sync) pushStrU8(buf, s); } buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0); const access = Array.isArray(stateLike.accessServers) ? stateLike.accessServers : []; buf.push(access.length & 0xff); for (const s of access) pushStrU8(buf, s); buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, Number(stateLike.trustedCount || 0) & 0xff); const recLen = buf.length + 64; buf[7] = recLen & 0xff; buf[8] = (recLen >>> 8) & 0xff; return new Uint8Array(buf); } function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber, lastBlockHash32, usedBytes) { 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); } function buildEd25519IxData(sig64, pubkey32, msgHash32) { const sigOff = 16; const pkOff = sigOff + 64; const msgOff = pkOff + 32; 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; } function serializeUpdateUserPdaArgs(args) { const b = []; for (const x of UPDATE_USER_PDA_DISCRIMINATOR) b.push(x); pushStrU32(b, args.login); for (const x of args.rootKey32) b.push(x); pushU64LE(b, args.createdAtMs); pushU64LE(b, args.updatedAtMs); pushU32LE(b, args.version); pushVecU8(b, args.prevHash32); pushU64LE(b, args.additionalLimitBytes); for (const x of args.deviceKey32) b.push(x); for (const x of args.blockchainPublicKey32) b.push(x); pushStrU32(b, args.blockchainName); pushU64LE(b, args.usedBytes); pushU32LE(b, args.lastBlockNumber); pushVecU8(b, args.lastBlockHash32); pushVecU8(b, args.lastBlockSignature64); pushStrU32(b, args.arweaveTxId); b.push(args.isServer ? 1 : 0); for (const x of args.serverKey32) b.push(x); pushStrU32(b, args.serverAddress); pushVecStrU32(b, args.syncServers); pushVecStrU32(b, args.accessServers); b.push(Number(args.trustedCount || 0) & 0xff); pushVecU8(b, args.rootSignature64); return new Uint8Array(b); } function parseUsersEconomyConfig(dataBytes) { const v = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength); if (dataBytes.byteLength < 25) throw new Error('Некорректный economy config'); return { version: v.getUint8(0), registrationFeeLamports: v.getBigUint64(1, true), lamportsPerLimitStep: v.getBigUint64(9, true), startBonusLimit: v.getBigUint64(17, true), }; } 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 ai = await connection.getAccountInfo(economyPda, 'confirmed'); if (!ai?.data) throw new Error('Economy config PDA не найден'); const economy = parseUsersEconomyConfig(ai.data); return { endpoint, economyPda: economyPda.toBase58(), ...economy }; } export async function getShineBlockchainUsage({ login, solanaEndpoint }) { const cleanLogin = String(login || '').trim().toLowerCase(); 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('login='), enc.encode(cleanLogin)], usersProgram); const ai = await connection.getAccountInfo(userPda, 'confirmed'); if (!ai?.data) throw new Error('Пользовательский PDA не найден в Solana'); const parsed = parseShineUserPda(ai.data); const bch = parsed.blockchain; const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n; return { endpoint, userPda: userPda.toBase58(), 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(''), }; } export async function updateShineUserPdaOnSolana({ login, solanaEndpoint, rootPrivatePkcs8B64, devicePrivatePkcs8B64, blockchainPrivatePkcs8B64, additionalLimitBytes = 0n, nextUsedBytes, nextLastBlockNumber, nextLastBlockHashHex, }) { const cleanLogin = String(login || '').trim().toLowerCase(); if (!cleanLogin) throw new Error('Не указан логин'); 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 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('login='), 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 userAi = await connection.getAccountInfo(userPda, 'confirmed'); if (!userAi?.data) throw new Error('PDA пользователя не найден'); const current = parseShineUserPda(userAi.data); const currentBch = current.blockchain; const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes); const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber); const effectiveLastHash = nextLastBlockHashHex ? Uint8Array.from(String(nextLastBlockHashHex).match(/.{1,2}/g).map((h) => parseInt(h, 16))) : currentBch.lastBlockHash; if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта'); const addLimit = BigInt(additionalLimitBytes || 0); if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит'); if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`); const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64); const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64); const deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64); const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32); const updatedAtMs = BigInt(Date.now()); const newPaid = currentBch.paidLimitBytes + addLimit; const newRecordNumber = current.recordNumber + 1; const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current)); const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, currentBch.blockchainName, effectiveLastNum, effectiveLastHash, effectiveUsed); const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes); const lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash); const nextState = { ...current, updatedAtMs, recordNumber: newRecordNumber, prevRecordHash: prevHash, blockchain: { ...currentBch, paidLimitBytes: newPaid, usedBytes: effectiveUsed, lastBlockNumber: effectiveLastNum, lastBlockHash: effectiveLastHash, lastBlockSignature: lastBlockSig64, }, }; const unsignedNext = serializeUnsignedRecordFromState(nextState); const unsignedNextHash = await sha256Bytes(unsignedNext); const rootSig64 = await signBytes(rootPriv, unsignedNextHash); const ixData = serializeUpdateUserPdaArgs({ login: cleanLogin, rootKey32: current.rootKey, createdAtMs: current.createdAtMs, updatedAtMs, version: newRecordNumber, prevHash32: prevHash, additionalLimitBytes: addLimit, deviceKey32: current.deviceKey, blockchainPublicKey32: currentBch.blockchainPublicKey, blockchainName: currentBch.blockchainName, usedBytes: effectiveUsed, lastBlockNumber: effectiveLastNum, lastBlockHash32: effectiveLastHash, lastBlockSignature64: lastBlockSig64, arweaveTxId: currentBch.arweaveTxId, isServer: current.isServer, serverKey32: current.serverKey, serverAddress: current.serverAddress, syncServers: current.syncServers, accessServers: current.accessServers, trustedCount: current.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 updIx = 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: economyPda, isSigner: false, isWritable: false }, ], data: ixData, }); const signature = await solana.sendAndConfirmTransaction( connection, new solana.Transaction().add(edIxRoot, edIxBch, updIx), [deviceKeypair], { commitment: 'confirmed' }, ); return { signature, userPda: 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 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; }