SHiNE-server/shine-UI/js/services/shine-blockchain-wallet-service.js

490 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}