SHA256
Перенести server UI в shine-UI и объединить PDA-модуль
This commit is contained in:
@@ -52,6 +52,34 @@ export function bytesToBase58(bytes) {
|
||||
return digits.reverse().map((digit) => BASE58_ALPHABET[digit]).join('');
|
||||
}
|
||||
|
||||
export function base58ToBytes(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return new Uint8Array();
|
||||
|
||||
const digits = [];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
const index = BASE58_ALPHABET.indexOf(char);
|
||||
if (index < 0) throw new Error(`Недопустимый символ base58: ${char}`);
|
||||
let carry = index;
|
||||
for (let j = 0; j < digits.length; j += 1) {
|
||||
const acc = (digits[j] * 58) + carry;
|
||||
digits[j] = acc & 0xff;
|
||||
carry = acc >> 8;
|
||||
}
|
||||
while (carry > 0) {
|
||||
digits.push(carry & 0xff);
|
||||
carry >>= 8;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < text.length && text[i] === '1'; i += 1) {
|
||||
digits.push(0);
|
||||
}
|
||||
|
||||
return new Uint8Array(digits.reverse());
|
||||
}
|
||||
|
||||
export function randomBase64(byteLen = 32) {
|
||||
const bytes = getCryptoApi().getRandomValues(new Uint8Array(byteLen));
|
||||
return bytesToBase64(bytes);
|
||||
|
||||
@@ -1,491 +1,7 @@
|
||||
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 computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 });
|
||||
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 131_072 });
|
||||
|
||||
const signature = await solana.sendAndConfirmTransaction(
|
||||
connection,
|
||||
new solana.Transaction().add(computeIx, heapIx, 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;
|
||||
}
|
||||
export {
|
||||
calcLimitTopupPriceLamports,
|
||||
getLimitStepBytes,
|
||||
getShineBlockchainUsage,
|
||||
getShineUsersEconomyConfig,
|
||||
updateShineUserPdaOnSolana,
|
||||
} from './shine-user-pda-service.js';
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
import { base64ToBytes, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from './device-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';
|
||||
|
||||
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 BLOCKCHAIN_TYPE_MAIN_USER = 1;
|
||||
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
|
||||
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 normalizeLogin(login) {
|
||||
return String(login || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
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 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 value of arr) pushStrU32(buf, value);
|
||||
}
|
||||
|
||||
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');
|
||||
};
|
||||
const readU8 = () => {
|
||||
ensure(1);
|
||||
const value = view.getUint8(offset);
|
||||
offset += 1;
|
||||
return value;
|
||||
};
|
||||
const readU16 = () => {
|
||||
ensure(2);
|
||||
const value = view.getUint16(offset, true);
|
||||
offset += 2;
|
||||
return value;
|
||||
};
|
||||
const readU32 = () => {
|
||||
ensure(4);
|
||||
const value = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
return value;
|
||||
};
|
||||
const readU64 = () => {
|
||||
ensure(8);
|
||||
const value = view.getBigUint64(offset, true);
|
||||
offset += 8;
|
||||
return value;
|
||||
};
|
||||
const readBytes = (len) => {
|
||||
ensure(len);
|
||||
const out = bytes.slice(offset, offset + len);
|
||||
offset += len;
|
||||
return out;
|
||||
};
|
||||
const readStrU8 = () => {
|
||||
const len = readU8();
|
||||
return new TextDecoder().decode(readBytes(len));
|
||||
};
|
||||
return {
|
||||
readU8,
|
||||
readU16,
|
||||
readU32,
|
||||
readU64,
|
||||
readBytes,
|
||||
readStrU8,
|
||||
get offset() {
|
||||
return 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, msgHash32) {
|
||||
const sigOff = 16;
|
||||
const pkOff = sigOff + 64;
|
||||
const msgOff = pkOff + 32;
|
||||
const data = new Uint8Array(msgOff + 32);
|
||||
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, 32, true);
|
||||
view.setUint16(14, 0xffff, true);
|
||||
data.set(sig64, sigOff);
|
||||
data.set(pubkey32, pkOff);
|
||||
data.set(msgHash32, msgOff);
|
||||
return data;
|
||||
}
|
||||
|
||||
function serializeCreateUserPdaArgs(args) {
|
||||
const buf = [];
|
||||
for (const x of CREATE_USER_PDA_DISCRIMINATOR) buf.push(x);
|
||||
pushStrU32(buf, args.login);
|
||||
for (const x of args.rootKey32) buf.push(x);
|
||||
pushU64LE(buf, args.createdAtMs);
|
||||
pushU64LE(buf, 0n);
|
||||
for (const x of args.deviceKey32) buf.push(x);
|
||||
for (const x of args.blockchainPublicKey32) buf.push(x);
|
||||
pushStrU32(buf, args.blockchainName);
|
||||
pushU64LE(buf, args.usedBytes);
|
||||
pushU32LE(buf, args.lastBlockNumber);
|
||||
pushVecU8(buf, args.lastBlockHash32);
|
||||
pushVecU8(buf, args.lastBlockSignature64);
|
||||
pushStrU32(buf, args.arweaveTxId);
|
||||
buf.push(args.isServer ? 1 : 0);
|
||||
buf.push(Number(args.addressFormatType || 0) & 0xff);
|
||||
buf.push(Number(args.addressFormatVersion || 0) & 0xff);
|
||||
pushStrU32(buf, args.serverAddress);
|
||||
pushVecStrU32(buf, args.syncServers);
|
||||
pushVecStrU32(buf, args.accessServers);
|
||||
buf.push(Number(args.trustedCount || 0) & 0xff);
|
||||
pushVecU8(buf, args.rootSignature64);
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
function serializeUpdateUserPdaArgs(args) {
|
||||
const buf = [];
|
||||
for (const x of UPDATE_USER_PDA_DISCRIMINATOR) buf.push(x);
|
||||
pushStrU32(buf, args.login);
|
||||
for (const x of args.rootKey32) buf.push(x);
|
||||
pushU64LE(buf, args.createdAtMs);
|
||||
pushU64LE(buf, args.updatedAtMs);
|
||||
pushU32LE(buf, args.version);
|
||||
pushVecU8(buf, args.prevHash32);
|
||||
pushU64LE(buf, args.additionalLimitBytes);
|
||||
for (const x of args.deviceKey32) buf.push(x);
|
||||
for (const x of args.blockchainPublicKey32) buf.push(x);
|
||||
pushStrU32(buf, args.blockchainName);
|
||||
pushU64LE(buf, args.usedBytes);
|
||||
pushU32LE(buf, args.lastBlockNumber);
|
||||
pushVecU8(buf, args.lastBlockHash32);
|
||||
pushVecU8(buf, args.lastBlockSignature64);
|
||||
pushStrU32(buf, args.arweaveTxId);
|
||||
buf.push(args.isServer ? 1 : 0);
|
||||
buf.push(Number(args.addressFormatType || 0) & 0xff);
|
||||
buf.push(Number(args.addressFormatVersion || 0) & 0xff);
|
||||
pushStrU32(buf, args.serverAddress);
|
||||
pushVecStrU32(buf, args.syncServers);
|
||||
pushVecStrU32(buf, args.accessServers);
|
||||
buf.push(Number(args.trustedCount || 0) & 0xff);
|
||||
pushVecU8(buf, args.rootSignature64);
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
function createBlockchainState({
|
||||
blockchainName,
|
||||
blockchainPublicKey,
|
||||
paidLimitBytes,
|
||||
usedBytes,
|
||||
lastBlockNumber,
|
||||
lastBlockHash,
|
||||
lastBlockSignature,
|
||||
arweaveTxId,
|
||||
}) {
|
||||
return {
|
||||
blockchainType: BLOCKCHAIN_TYPE_MAIN_USER,
|
||||
blockchainName,
|
||||
blockchainPublicKey,
|
||||
paidLimitBytes,
|
||||
usedBytes,
|
||||
lastBlockNumber,
|
||||
lastBlockHash,
|
||||
lastBlockSignature,
|
||||
arweaveTxId,
|
||||
};
|
||||
}
|
||||
|
||||
function createPdaState({
|
||||
login,
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
recordNumber,
|
||||
prevRecordHash,
|
||||
rootKey,
|
||||
deviceKey,
|
||||
blockchain,
|
||||
isServer,
|
||||
addressFormatType,
|
||||
addressFormatVersion,
|
||||
serverAddress,
|
||||
syncServers,
|
||||
accessServers,
|
||||
trustedCount,
|
||||
}) {
|
||||
const serverProfile = isServer ? {
|
||||
addressFormatType: Number(addressFormatType || 0),
|
||||
addressFormatVersion: Number(addressFormatVersion || 0),
|
||||
serverAddress: String(serverAddress || ''),
|
||||
syncServers: Array.isArray(syncServers) ? [...syncServers] : [],
|
||||
} : null;
|
||||
return {
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
recordNumber,
|
||||
prevRecordHash,
|
||||
login,
|
||||
rootKey,
|
||||
deviceKey,
|
||||
blockchain,
|
||||
isServer: Boolean(isServer),
|
||||
serverProfile,
|
||||
serverData: serverProfile,
|
||||
addressFormatType: serverProfile?.addressFormatType ?? 0,
|
||||
addressFormatVersion: serverProfile?.addressFormatVersion ?? 0,
|
||||
serverAddress: serverProfile?.serverAddress ?? '',
|
||||
syncServers: serverProfile?.syncServers ? [...serverProfile.syncServers] : [],
|
||||
accessServers: Array.isArray(accessServers) ? [...accessServers] : [],
|
||||
trustedCount: Number(trustedCount || 0) & 0xff,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeOptionalArweave(buf, arweaveTxId) {
|
||||
const value = String(arweaveTxId || '').trim();
|
||||
if (value) {
|
||||
buf.push(1);
|
||||
pushStrU8(buf, value);
|
||||
} else {
|
||||
buf.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber = 0, lastBlockHash32 = new Uint8Array(32), usedBytes = 0n) {
|
||||
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);
|
||||
}
|
||||
|
||||
export function parseShineUserPda(dataBytes) {
|
||||
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
|
||||
const reader = makeReader(bytes);
|
||||
const magic = new TextDecoder().decode(reader.readBytes(5));
|
||||
if (magic !== MAGIC) throw new Error('Некорректный формат PDA');
|
||||
reader.readU8();
|
||||
reader.readU8();
|
||||
const recordLen = reader.readU16();
|
||||
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();
|
||||
|
||||
let rootKey = null;
|
||||
let deviceKey = null;
|
||||
let blockchain = null;
|
||||
let isServer = false;
|
||||
let addressFormatType = 0;
|
||||
let addressFormatVersion = 0;
|
||||
let serverAddress = '';
|
||||
let syncServers = [];
|
||||
let accessServers = [];
|
||||
let trustedCount = 0;
|
||||
|
||||
for (let i = 0; i < blocksCount; i += 1) {
|
||||
const blockType = reader.readU8();
|
||||
reader.readU8();
|
||||
|
||||
if (blockType === BLOCK_TYPE_ROOT_KEY) {
|
||||
rootKey = reader.readBytes(32);
|
||||
continue;
|
||||
}
|
||||
if (blockType === BLOCK_TYPE_DEVICE_KEY) {
|
||||
deviceKey = reader.readBytes(32);
|
||||
continue;
|
||||
}
|
||||
if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) {
|
||||
const count = reader.readU8();
|
||||
for (let j = 0; j < count; j += 1) {
|
||||
const blockchainType = reader.readU8();
|
||||
const blockchainName = reader.readStrU8();
|
||||
const blockchainPublicKey = reader.readBytes(32);
|
||||
const paidLimitBytes = reader.readU64();
|
||||
const usedBytes = reader.readU64();
|
||||
const lastBlockNumber = reader.readU32();
|
||||
const lastBlockHash = reader.readBytes(32);
|
||||
const lastBlockSignature = reader.readBytes(64);
|
||||
const arweavePresent = reader.readU8();
|
||||
const arweaveTxId = arweavePresent === 1 ? reader.readStrU8() : '';
|
||||
if (!blockchain) {
|
||||
blockchain = {
|
||||
blockchainType,
|
||||
blockchainName,
|
||||
blockchainPublicKey,
|
||||
paidLimitBytes,
|
||||
usedBytes,
|
||||
lastBlockNumber,
|
||||
lastBlockHash,
|
||||
lastBlockSignature,
|
||||
arweaveTxId,
|
||||
};
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockType === BLOCK_TYPE_SERVER_PROFILE) {
|
||||
isServer = reader.readU8() === 1;
|
||||
if (isServer) {
|
||||
addressFormatType = reader.readU8();
|
||||
addressFormatVersion = reader.readU8();
|
||||
serverAddress = reader.readStrU8();
|
||||
const syncCount = reader.readU8();
|
||||
syncServers = [];
|
||||
for (let j = 0; j < syncCount; j += 1) syncServers.push(reader.readStrU8());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockType === BLOCK_TYPE_ACCESS_SERVERS) {
|
||||
const accessCount = reader.readU8();
|
||||
accessServers = [];
|
||||
for (let j = 0; j < accessCount; j += 1) accessServers.push(reader.readStrU8());
|
||||
continue;
|
||||
}
|
||||
if (blockType === BLOCK_TYPE_TRUSTED_STATE) {
|
||||
trustedCount = reader.readU8();
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Неизвестный блок PDA: ${blockType}`);
|
||||
}
|
||||
|
||||
if (!rootKey || !deviceKey || !blockchain) {
|
||||
throw new Error('В PDA отсутствуют обязательные блоки');
|
||||
}
|
||||
|
||||
const signature = bytes.slice(reader.offset, reader.offset + 64);
|
||||
const unsignedBytes = bytes.slice(0, recordLen - 64);
|
||||
const state = createPdaState({
|
||||
login,
|
||||
createdAtMs,
|
||||
updatedAtMs,
|
||||
recordNumber,
|
||||
prevRecordHash,
|
||||
rootKey,
|
||||
deviceKey,
|
||||
blockchain,
|
||||
isServer,
|
||||
addressFormatType,
|
||||
addressFormatVersion,
|
||||
serverAddress,
|
||||
syncServers,
|
||||
accessServers,
|
||||
trustedCount,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
recordLen,
|
||||
unsignedBytes,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeUnsignedRecordFromState(stateLike) {
|
||||
const state = createPdaState({
|
||||
login: stateLike.login,
|
||||
createdAtMs: stateLike.createdAtMs,
|
||||
updatedAtMs: stateLike.updatedAtMs,
|
||||
recordNumber: stateLike.recordNumber,
|
||||
prevRecordHash: stateLike.prevRecordHash,
|
||||
rootKey: stateLike.rootKey,
|
||||
deviceKey: stateLike.deviceKey,
|
||||
blockchain: stateLike.blockchain,
|
||||
isServer: stateLike.isServer,
|
||||
addressFormatType: stateLike.addressFormatType ?? stateLike.serverProfile?.addressFormatType,
|
||||
addressFormatVersion: stateLike.addressFormatVersion ?? stateLike.serverProfile?.addressFormatVersion,
|
||||
serverAddress: stateLike.serverAddress ?? stateLike.serverProfile?.serverAddress,
|
||||
syncServers: stateLike.syncServers ?? stateLike.serverProfile?.syncServers,
|
||||
accessServers: stateLike.accessServers,
|
||||
trustedCount: stateLike.trustedCount,
|
||||
});
|
||||
|
||||
const buf = [0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0];
|
||||
pushU64LE(buf, state.createdAtMs);
|
||||
pushU64LE(buf, state.updatedAtMs);
|
||||
pushU32LE(buf, state.recordNumber);
|
||||
for (const x of state.prevRecordHash) buf.push(x);
|
||||
pushStrU8(buf, state.login);
|
||||
buf.push(state.isServer ? 6 : 5);
|
||||
|
||||
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
|
||||
for (const x of state.rootKey) buf.push(x);
|
||||
|
||||
buf.push(BLOCK_TYPE_DEVICE_KEY, 0);
|
||||
for (const x of state.deviceKey) buf.push(x);
|
||||
|
||||
buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1, state.blockchain.blockchainType);
|
||||
pushStrU8(buf, state.blockchain.blockchainName);
|
||||
for (const x of state.blockchain.blockchainPublicKey) buf.push(x);
|
||||
pushU64LE(buf, state.blockchain.paidLimitBytes);
|
||||
pushU64LE(buf, state.blockchain.usedBytes);
|
||||
pushU32LE(buf, state.blockchain.lastBlockNumber);
|
||||
for (const x of state.blockchain.lastBlockHash) buf.push(x);
|
||||
for (const x of state.blockchain.lastBlockSignature) buf.push(x);
|
||||
encodeOptionalArweave(buf, state.blockchain.arweaveTxId);
|
||||
|
||||
if (state.isServer) {
|
||||
buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1);
|
||||
buf.push(state.addressFormatType & 0xff);
|
||||
buf.push(state.addressFormatVersion & 0xff);
|
||||
pushStrU8(buf, state.serverAddress);
|
||||
buf.push(state.syncServers.length & 0xff);
|
||||
for (const loginValue of state.syncServers) pushStrU8(buf, loginValue);
|
||||
}
|
||||
|
||||
buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0, state.accessServers.length & 0xff);
|
||||
for (const loginValue of state.accessServers) pushStrU8(buf, loginValue);
|
||||
|
||||
buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, state.trustedCount & 0xff);
|
||||
|
||||
const recordLen = buf.length + 64;
|
||||
buf[7] = recordLen & 0xff;
|
||||
buf[8] = (recordLen >>> 8) & 0xff;
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
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 accountInfo = await connection.getAccountInfo(economyPda, 'confirmed');
|
||||
if (!accountInfo?.data) throw new Error('Economy config PDA не найден');
|
||||
return { endpoint, economyPda: economyPda.toBase58(), ...parseUsersEconomyConfig(accountInfo.data) };
|
||||
}
|
||||
|
||||
export async function readShineUserPda({ login, solanaEndpoint }) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
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 accountInfo = await connection.getAccountInfo(userPda, 'confirmed');
|
||||
if (!accountInfo?.data) throw new Error(`PDA не найдена для логина «${cleanLogin}»`);
|
||||
return {
|
||||
...parseShineUserPda(accountInfo.data),
|
||||
userPda: userPda.toBase58(),
|
||||
pdaAddress: userPda.toBase58(),
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getShineBlockchainUsage({ login, solanaEndpoint }) {
|
||||
const parsed = await readShineUserPda({ login, solanaEndpoint });
|
||||
const bch = parsed.blockchain;
|
||||
const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n;
|
||||
return {
|
||||
endpoint: parsed.endpoint,
|
||||
userPda: parsed.userPda,
|
||||
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(''),
|
||||
};
|
||||
}
|
||||
|
||||
function parseHex32(value) {
|
||||
const clean = String(value || '').trim().toLowerCase();
|
||||
if (!clean) return null;
|
||||
if (!/^[0-9a-f]+$/.test(clean) || clean.length !== 64) {
|
||||
throw new Error('last block hash должен быть 32 байта в hex');
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
out[i] = parseInt(clean.slice(i * 2, (i * 2) + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
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 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('login='), 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 rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
||||
const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
|
||||
const deviceKey32 = base64ToBytes(keyBundle.devicePair.publicKeyB64);
|
||||
|
||||
const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
|
||||
const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
|
||||
const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64);
|
||||
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
|
||||
|
||||
return {
|
||||
cleanLogin,
|
||||
endpoint,
|
||||
solana,
|
||||
connection,
|
||||
usersProgram,
|
||||
paymentsProgram,
|
||||
loginGuardProgram,
|
||||
ed25519Program,
|
||||
sysvarInstructions,
|
||||
userPda,
|
||||
economyConfigPda,
|
||||
inflowVault,
|
||||
rootKey32,
|
||||
blockchainKey32,
|
||||
deviceKey32,
|
||||
rootPrivKey,
|
||||
bchPrivKey,
|
||||
deviceKeypair,
|
||||
};
|
||||
}
|
||||
|
||||
async function createShineUserPdaOnSolana({
|
||||
login,
|
||||
keyBundle,
|
||||
solanaEndpoint,
|
||||
isServer = false,
|
||||
addressFormatType = 0,
|
||||
addressFormatVersion = 0,
|
||||
serverAddress = '',
|
||||
syncServers = [],
|
||||
accessServers = [],
|
||||
}) {
|
||||
const ctx = await buildCreateContext({ login, keyBundle, solanaEndpoint });
|
||||
const ecoAccount = await ctx.connection.getAccountInfo(ctx.economyConfigPda);
|
||||
if (!ecoAccount?.data) {
|
||||
throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.');
|
||||
}
|
||||
|
||||
const cleanLogin = ctx.cleanLogin;
|
||||
const blockchainName = `${cleanLogin}-001`;
|
||||
const zeroHash32 = new Uint8Array(32);
|
||||
const createdAtMs = BigInt(Date.now());
|
||||
const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit;
|
||||
|
||||
const lastBlockStateBytes = buildLastBlockStateBytes(cleanLogin, blockchainName, 0, zeroHash32, 0n);
|
||||
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
|
||||
const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash);
|
||||
|
||||
const initialState = createPdaState({
|
||||
login: cleanLogin,
|
||||
createdAtMs,
|
||||
updatedAtMs: createdAtMs,
|
||||
recordNumber: 0,
|
||||
prevRecordHash: zeroHash32,
|
||||
rootKey: ctx.rootKey32,
|
||||
deviceKey: ctx.deviceKey32,
|
||||
blockchain: createBlockchainState({
|
||||
blockchainName,
|
||||
blockchainPublicKey: ctx.blockchainKey32,
|
||||
paidLimitBytes: startBonusLimit,
|
||||
usedBytes: 0n,
|
||||
lastBlockNumber: 0,
|
||||
lastBlockHash: zeroHash32,
|
||||
lastBlockSignature: lastBlockSig64,
|
||||
arweaveTxId: '',
|
||||
}),
|
||||
isServer,
|
||||
addressFormatType,
|
||||
addressFormatVersion,
|
||||
serverAddress,
|
||||
syncServers,
|
||||
accessServers,
|
||||
trustedCount: 0,
|
||||
});
|
||||
|
||||
const unsignedRecord = serializeUnsignedRecordFromState(initialState);
|
||||
const unsignedHash = await sha256Bytes(unsignedRecord);
|
||||
const rootSig64 = await signBytes(ctx.rootPrivKey, unsignedHash);
|
||||
|
||||
const ixData = serializeCreateUserPdaArgs({
|
||||
login: cleanLogin,
|
||||
rootKey32: ctx.rootKey32,
|
||||
createdAtMs,
|
||||
deviceKey32: ctx.deviceKey32,
|
||||
blockchainPublicKey32: ctx.blockchainKey32,
|
||||
blockchainName,
|
||||
usedBytes: 0n,
|
||||
lastBlockNumber: 0,
|
||||
lastBlockHash32: zeroHash32,
|
||||
lastBlockSignature64: lastBlockSig64,
|
||||
arweaveTxId: '',
|
||||
isServer,
|
||||
addressFormatType: isServer ? addressFormatType : 0,
|
||||
addressFormatVersion: isServer ? addressFormatVersion : 0,
|
||||
serverAddress: isServer ? serverAddress : '',
|
||||
syncServers: isServer ? syncServers : [],
|
||||
accessServers,
|
||||
trustedCount: 0,
|
||||
rootSignature64: rootSig64,
|
||||
});
|
||||
|
||||
const ed25519RootIx = new ctx.solana.TransactionInstruction({
|
||||
programId: ctx.ed25519Program,
|
||||
keys: [],
|
||||
data: buildEd25519IxData(rootSig64, ctx.rootKey32, unsignedHash),
|
||||
});
|
||||
const ed25519BchIx = new ctx.solana.TransactionInstruction({
|
||||
programId: ctx.ed25519Program,
|
||||
keys: [],
|
||||
data: buildEd25519IxData(lastBlockSig64, ctx.blockchainKey32, lastBlockStateHash),
|
||||
});
|
||||
const createIx = new ctx.solana.TransactionInstruction({
|
||||
programId: ctx.usersProgram,
|
||||
keys: [
|
||||
{ pubkey: ctx.deviceKeypair.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 },
|
||||
],
|
||||
data: ixData,
|
||||
});
|
||||
|
||||
const signature = await ctx.solana.sendAndConfirmTransaction(
|
||||
ctx.connection,
|
||||
new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx),
|
||||
[ctx.deviceKeypair],
|
||||
{ commitment: 'confirmed' },
|
||||
);
|
||||
|
||||
return {
|
||||
signature,
|
||||
userPda: ctx.userPda.toBase58(),
|
||||
pdaAddress: ctx.userPda.toBase58(),
|
||||
blockchainName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) {
|
||||
return createShineUserPdaOnSolana({
|
||||
login,
|
||||
keyBundle,
|
||||
solanaEndpoint,
|
||||
isServer: false,
|
||||
accessServers: ['shineup.me'],
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerServerOnSolana({
|
||||
login,
|
||||
keyBundle,
|
||||
serverAddress,
|
||||
addressFormatType = 1,
|
||||
addressFormatVersion = 0,
|
||||
syncServers = [],
|
||||
accessServers = [],
|
||||
solanaEndpoint,
|
||||
}) {
|
||||
return createShineUserPdaOnSolana({
|
||||
login,
|
||||
keyBundle,
|
||||
solanaEndpoint,
|
||||
isServer: true,
|
||||
addressFormatType,
|
||||
addressFormatVersion,
|
||||
serverAddress,
|
||||
syncServers,
|
||||
accessServers,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateShineUserPdaOnSolana({
|
||||
login,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64,
|
||||
devicePrivatePkcs8B64,
|
||||
blockchainPrivatePkcs8B64,
|
||||
additionalLimitBytes = 0n,
|
||||
nextUsedBytes,
|
||||
nextLastBlockNumber,
|
||||
nextLastBlockHashHex,
|
||||
serverProfile,
|
||||
accessServers,
|
||||
trustedCount,
|
||||
}) {
|
||||
const current = await readShineUserPda({ login, solanaEndpoint });
|
||||
const cleanLogin = current.login;
|
||||
const endpoint = current.endpoint;
|
||||
const currentBch = current.blockchain;
|
||||
const addLimit = BigInt(additionalLimitBytes || 0);
|
||||
if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит');
|
||||
if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`);
|
||||
|
||||
const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes);
|
||||
const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber);
|
||||
const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash;
|
||||
if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта');
|
||||
|
||||
const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64);
|
||||
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 deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);
|
||||
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
|
||||
|
||||
const lastBlockStateBytes = buildLastBlockStateBytes(
|
||||
cleanLogin,
|
||||
currentBch.blockchainName,
|
||||
effectiveLastNum,
|
||||
effectiveLastHash,
|
||||
effectiveUsed,
|
||||
);
|
||||
const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes);
|
||||
|
||||
const blockchainStateWillChange = (
|
||||
nextUsedBytes != null
|
||||
|| nextLastBlockNumber != null
|
||||
|| nextLastBlockHashHex != null
|
||||
);
|
||||
|
||||
let lastBlockSig64 = currentBch.lastBlockSignature;
|
||||
if (blockchainPrivatePkcs8B64) {
|
||||
const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64);
|
||||
lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash);
|
||||
} else if (blockchainStateWillChange) {
|
||||
throw new Error('Для изменения last block state нужен blockchain-ключ');
|
||||
}
|
||||
|
||||
const updatedAtMs = BigInt(Date.now());
|
||||
const newPaid = currentBch.paidLimitBytes + addLimit;
|
||||
const newRecordNumber = current.recordNumber + 1;
|
||||
const prevHash = await sha256Bytes(serializeUnsignedRecordFromState(current));
|
||||
|
||||
const nextServerProfile = serverProfile
|
||||
? {
|
||||
addressFormatType: Number(serverProfile.addressFormatType ?? current.addressFormatType ?? 0),
|
||||
addressFormatVersion: Number(serverProfile.addressFormatVersion ?? current.addressFormatVersion ?? 0),
|
||||
serverAddress: String(serverProfile.serverAddress ?? current.serverAddress ?? ''),
|
||||
syncServers: Array.isArray(serverProfile.syncServers) ? [...serverProfile.syncServers] : [...current.syncServers],
|
||||
}
|
||||
: current.serverProfile;
|
||||
|
||||
const nextState = createPdaState({
|
||||
login: cleanLogin,
|
||||
createdAtMs: current.createdAtMs,
|
||||
updatedAtMs,
|
||||
recordNumber: newRecordNumber,
|
||||
prevRecordHash: prevHash,
|
||||
rootKey: current.rootKey,
|
||||
deviceKey: current.deviceKey,
|
||||
blockchain: createBlockchainState({
|
||||
blockchainName: currentBch.blockchainName,
|
||||
blockchainPublicKey: currentBch.blockchainPublicKey,
|
||||
paidLimitBytes: newPaid,
|
||||
usedBytes: effectiveUsed,
|
||||
lastBlockNumber: effectiveLastNum,
|
||||
lastBlockHash: effectiveLastHash,
|
||||
lastBlockSignature: lastBlockSig64,
|
||||
arweaveTxId: currentBch.arweaveTxId,
|
||||
}),
|
||||
isServer: Boolean(nextServerProfile),
|
||||
addressFormatType: nextServerProfile?.addressFormatType ?? 0,
|
||||
addressFormatVersion: nextServerProfile?.addressFormatVersion ?? 0,
|
||||
serverAddress: nextServerProfile?.serverAddress ?? '',
|
||||
syncServers: nextServerProfile?.syncServers ?? [],
|
||||
accessServers: accessServers == null ? current.accessServers : accessServers,
|
||||
trustedCount: trustedCount == null ? current.trustedCount : trustedCount,
|
||||
});
|
||||
|
||||
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: nextState.isServer,
|
||||
addressFormatType: nextState.addressFormatType,
|
||||
addressFormatVersion: nextState.addressFormatVersion,
|
||||
serverAddress: nextState.serverAddress,
|
||||
syncServers: nextState.syncServers,
|
||||
accessServers: nextState.accessServers,
|
||||
trustedCount: nextState.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 updateIx = 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 computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 });
|
||||
const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 131_072 });
|
||||
|
||||
const signature = await solana.sendAndConfirmTransaction(
|
||||
connection,
|
||||
new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updateIx),
|
||||
[deviceKeypair],
|
||||
{ commitment: 'confirmed' },
|
||||
);
|
||||
|
||||
return {
|
||||
signature,
|
||||
userPda: userPda.toBase58(),
|
||||
pdaAddress: 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 async function updateServerOnSolana({
|
||||
login,
|
||||
keyBundle,
|
||||
serverAddress,
|
||||
addressFormatType,
|
||||
addressFormatVersion,
|
||||
syncServers,
|
||||
solanaEndpoint,
|
||||
}) {
|
||||
return updateShineUserPdaOnSolana({
|
||||
login,
|
||||
solanaEndpoint,
|
||||
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
|
||||
devicePrivatePkcs8B64: keyBundle.devicePair.privatePkcs8B64,
|
||||
serverProfile: {
|
||||
addressFormatType,
|
||||
addressFormatVersion,
|
||||
serverAddress,
|
||||
syncServers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
import { sha256Bytes, signBytes, importPkcs8Ed25519, base64ToBytes } from './crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from './device-key-utils.js';
|
||||
import { registerUserOnSolana as registerUserOnSolanaShared } from './shine-user-pda-service.js';
|
||||
import {
|
||||
SHINE_USERS_PROGRAM_ID,
|
||||
SHINE_PAYMENTS_PROGRAM_ID,
|
||||
SHINE_LOGIN_GUARD_PROGRAM_ID,
|
||||
} from '../solana-programs.js';
|
||||
|
||||
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
|
||||
const CLASSIFY_LOGIN_DISCRIMINATOR = new Uint8Array([112, 97, 152, 32, 255, 73, 108, 86]);
|
||||
const PRECHECK_SIM_PAYER = 'FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P';
|
||||
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
|
||||
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
|
||||
|
||||
let solanaLibPromise = null;
|
||||
function loadSolanaLib() {
|
||||
@@ -23,169 +18,19 @@ function pushU32LE(buf, v) {
|
||||
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); }
|
||||
}
|
||||
|
||||
// Matches Rust serialize_last_block_state (initial zero state)
|
||||
function buildLastBlockStateBytes(login, blockchainName) {
|
||||
const enc = new TextEncoder();
|
||||
const prefix = enc.encode('SHiNE_LAST_BLOCK');
|
||||
const loginB = enc.encode(login);
|
||||
const bchB = enc.encode(blockchainName);
|
||||
const buf = [];
|
||||
for (const x of prefix) buf.push(x);
|
||||
buf.push(loginB.length);
|
||||
for (const x of loginB) buf.push(x);
|
||||
buf.push(bchB.length);
|
||||
for (const x of bchB) buf.push(x);
|
||||
pushU32LE(buf, 0); // last_block_number = 0
|
||||
for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32]
|
||||
pushU64LE(buf, 0n); // used_bytes = 0
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
// Matches Rust serialize_unsigned_record for initial registration
|
||||
function buildUnsignedRecordBytes(
|
||||
login, createdAtMs, rootKey32, deviceKey32, blockchainKey32,
|
||||
blockchainName, paidLimitBytes, lastBlockSig64,
|
||||
) {
|
||||
const enc = new TextEncoder();
|
||||
const loginB = enc.encode(login);
|
||||
const bchB = enc.encode(blockchainName);
|
||||
const accessB = enc.encode('shineup.me');
|
||||
const buf = [];
|
||||
|
||||
// Fixed header: MAGIC(5) + FORMAT_MAJOR(1) + FORMAT_MINOR(1) + record_len_placeholder(2)
|
||||
buf.push(0x53, 0x48, 0x69, 0x4E, 0x45, 1, 0, 0, 0); // indices 0..8
|
||||
|
||||
pushU64LE(buf, createdAtMs); // created_at_ms
|
||||
pushU64LE(buf, createdAtMs); // updated_at_ms = same
|
||||
pushU32LE(buf, 0); // record_number = 0
|
||||
for (let i = 0; i < 32; i++) buf.push(0); // prev_record_hash = [0;32]
|
||||
|
||||
buf.push(loginB.length);
|
||||
for (const x of loginB) buf.push(x);
|
||||
|
||||
buf.push(5); // blocks_count (non-server)
|
||||
|
||||
// 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)
|
||||
buf.push(3, 0, 1, 1); // type, ver, count=1, blockchain_type=1(MAIN_USER)
|
||||
buf.push(bchB.length);
|
||||
for (const x of bchB) buf.push(x);
|
||||
for (const x of blockchainKey32) buf.push(x);
|
||||
pushU64LE(buf, paidLimitBytes); // paid_limit_bytes
|
||||
pushU64LE(buf, 0n); // used_bytes = 0
|
||||
pushU32LE(buf, 0); // last_block_number = 0
|
||||
for (let i = 0; i < 32; i++) buf.push(0); // last_block_hash = [0;32]
|
||||
for (const x of lastBlockSig64) buf.push(x); // last_block_signature
|
||||
buf.push(0); // arweave_present = 0
|
||||
|
||||
// AccessServersBlock (type=40, ver=0)
|
||||
buf.push(40, 0, 1, accessB.length);
|
||||
for (const x of accessB) buf.push(x);
|
||||
|
||||
// TrustedStateBlock (type=50, ver=0, trusted_count=0)
|
||||
buf.push(50, 0, 0);
|
||||
|
||||
// Patch record_len at indices 7-8: total = buf.length + 64 (signature)
|
||||
const recLen = buf.length + 64;
|
||||
buf[7] = recLen & 0xFF;
|
||||
buf[8] = (recLen >> 8) & 0xFF;
|
||||
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
// Builds Ed25519 program instruction data for one signature
|
||||
function buildEd25519IxData(sig64, pubkey32, msgHash32) {
|
||||
const sigOff = 16;
|
||||
const pkOff = sigOff + 64; // 80
|
||||
const msgOff = pkOff + 32; // 112
|
||||
const data = new Uint8Array(msgOff + 32); // 144 bytes total
|
||||
const v = new DataView(data.buffer);
|
||||
data[0] = 1; data[1] = 0; // num_signatures=1, padding
|
||||
v.setUint16(2, sigOff, true);
|
||||
v.setUint16(4, 0xFFFF, true); // same instruction
|
||||
v.setUint16(6, pkOff, true);
|
||||
v.setUint16(8, 0xFFFF, true);
|
||||
v.setUint16(10, msgOff, true);
|
||||
v.setUint16(12, 32, true); // message_data_size = 32
|
||||
v.setUint16(14, 0xFFFF, true);
|
||||
data.set(sig64, sigOff);
|
||||
data.set(pubkey32, pkOff);
|
||||
data.set(msgHash32, msgOff);
|
||||
return data;
|
||||
}
|
||||
|
||||
function readStartBonusLimit(data) {
|
||||
// Borsh: version(u8=1) + reg_fee(u64=8) + lamports_per_step(u64=8) + start_bonus_limit(u64=8)
|
||||
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
return v.getBigUint64(17, true);
|
||||
}
|
||||
|
||||
function serializeCreateUserPdaArgs(
|
||||
login, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
|
||||
blockchainName, lastBlockSig64, 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(0n); // used_bytes
|
||||
b.u32(0); // last_block_number
|
||||
b.vecU8(new Uint8Array(32)); // last_block_hash
|
||||
b.vecU8(lastBlockSig64); // last_block_signature
|
||||
b.str(''); // arweave_tx_id
|
||||
b.bool(false); // is_server
|
||||
b.u8(0); // address_format_type
|
||||
b.u8(0); // address_format_version
|
||||
b.str(''); // server_address
|
||||
b.vecStr([]); // sync_servers
|
||||
b.vecStr(['shineup.me']); // access_servers
|
||||
b.u8(0); // trusted_count
|
||||
b.vecU8(rootSig64); // signature
|
||||
return b.result();
|
||||
}
|
||||
|
||||
function serializeClassifyLoginArgs(login) {
|
||||
const b = new BorshBuf();
|
||||
b.raw(CLASSIFY_LOGIN_DISCRIMINATOR);
|
||||
@@ -284,98 +129,5 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
|
||||
}
|
||||
|
||||
export async function registerUserOnSolana({ login, keyBundle, 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 = login.toLowerCase();
|
||||
const blockchainName = `${loginNorm}-001`;
|
||||
|
||||
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 rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64);
|
||||
const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64);
|
||||
|
||||
const deviceSeed32 = extractSeed32FromPkcs8B64(keyBundle.devicePair.privatePkcs8B64);
|
||||
const deviceKeypair = solana.Keypair.fromSeed(deviceSeed32);
|
||||
|
||||
const ecoAccount = await connection.getAccountInfo(economyConfigPda);
|
||||
if (!ecoAccount) {
|
||||
throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.');
|
||||
}
|
||||
const startBonusLimit = readStartBonusLimit(ecoAccount.data);
|
||||
const createdAtMs = BigInt(Date.now());
|
||||
|
||||
// Sign LastBlockState with blockchain key
|
||||
const lbsBytes = buildLastBlockStateBytes(loginNorm, blockchainName);
|
||||
const lbsHash = await sha256Bytes(lbsBytes);
|
||||
const lastBlockSig64 = await signBytes(bchPrivKey, lbsHash);
|
||||
|
||||
// Build and sign unsigned PDA record with root key
|
||||
const unsignedRecord = buildUnsignedRecordBytes(
|
||||
loginNorm, createdAtMs, rootKey32, deviceKey32, blockchainKey32,
|
||||
blockchainName, startBonusLimit, lastBlockSig64,
|
||||
);
|
||||
const unsignedHash = await sha256Bytes(unsignedRecord);
|
||||
const rootSig64 = await signBytes(rootPrivKey, unsignedHash);
|
||||
|
||||
const ixData = serializeCreateUserPdaArgs(
|
||||
loginNorm, rootKey32, createdAtMs, deviceKey32, blockchainKey32,
|
||||
blockchainName, lastBlockSig64, rootSig64,
|
||||
);
|
||||
|
||||
// Ed25519 instructions must precede create_user_pda
|
||||
const ed25519RootIx = new solana.TransactionInstruction({
|
||||
programId: ed25519Program,
|
||||
keys: [],
|
||||
data: buildEd25519IxData(rootSig64, rootKey32, unsignedHash),
|
||||
});
|
||||
const ed25519BchIx = new solana.TransactionInstruction({
|
||||
programId: ed25519Program,
|
||||
keys: [],
|
||||
data: buildEd25519IxData(lastBlockSig64, blockchainKey32, lbsHash),
|
||||
});
|
||||
const createUserIx = 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 sig = await solana.sendAndConfirmTransaction(
|
||||
connection,
|
||||
new solana.Transaction().add(ed25519RootIx, ed25519BchIx, createUserIx),
|
||||
[deviceKeypair],
|
||||
{ commitment: 'confirmed' },
|
||||
);
|
||||
|
||||
return { signature: sig, blockchainName };
|
||||
return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user