SHA256
UI: обновлена шапка каналов, закрыты pending-задачи и обновлены версии
This commit is contained in:
@@ -37,6 +37,7 @@ import * as entrySettingsView from './pages/entry-settings-view.js';
|
||||
import * as registerView from './pages/register-view.js';
|
||||
import * as registrationPaymentView from './pages/registration-payment-view.js';
|
||||
import * as registrationKeysView from './pages/registration-keys-view.js';
|
||||
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
||||
import * as topupView from './pages/topup-view.js';
|
||||
import * as loginView from './pages/login-view.js';
|
||||
import * as loginCameraView from './pages/login-camera-view.js';
|
||||
@@ -78,6 +79,7 @@ const routes = {
|
||||
'register-view': registerView,
|
||||
'registration-payment-view': registrationPaymentView,
|
||||
'registration-keys-view': registrationKeysView,
|
||||
'registration-draft-keys-view': registrationDraftKeysView,
|
||||
'topup-view': topupView,
|
||||
'login-view': loginView,
|
||||
'login-camera-view': loginCameraView,
|
||||
|
||||
@@ -1173,34 +1173,52 @@ export function render({ navigate, route }) {
|
||||
const topBarEl = document.createElement('div');
|
||||
topBarEl.className = 'channels-top-bar';
|
||||
|
||||
const tabsEl = document.createElement('div');
|
||||
tabsEl.className = 'channels-tabs';
|
||||
const tabLabels = {
|
||||
feed: 'Каналы',
|
||||
my: 'Мои каналы',
|
||||
};
|
||||
TAB_ORDER.forEach((tabKey) => {
|
||||
if (isGuest && tabKey === 'my') return;
|
||||
const tabBtn = document.createElement('button');
|
||||
tabBtn.type = 'button';
|
||||
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
||||
tabBtn.textContent = tabLabels[tabKey] || tabKey;
|
||||
tabBtn.addEventListener('click', () => {
|
||||
if (listState.activeTab === tabKey) return;
|
||||
listState.activeTab = tabKey;
|
||||
rerenderList();
|
||||
});
|
||||
tabsEl.append(tabBtn);
|
||||
const topBarLeft = document.createElement('div');
|
||||
topBarLeft.className = 'channels-top-left';
|
||||
|
||||
const backToFeedBtn = document.createElement('button');
|
||||
backToFeedBtn.type = 'button';
|
||||
backToFeedBtn.className = 'icon-btn channels-top-back-btn';
|
||||
backToFeedBtn.textContent = '←';
|
||||
backToFeedBtn.setAttribute('aria-label', 'Назад');
|
||||
backToFeedBtn.addEventListener('click', () => {
|
||||
if (listState.activeTab === 'feed') return;
|
||||
listState.activeTab = 'feed';
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
const topActionBtn = document.createElement('button');
|
||||
topActionBtn.type = 'button';
|
||||
topActionBtn.className = 'secondary-btn channels-top-action-btn';
|
||||
topActionBtn.textContent = 'Создать канал';
|
||||
topActionBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||
if (isGuest) topActionBtn.style.display = 'none';
|
||||
const allChannelsBtn = document.createElement('button');
|
||||
allChannelsBtn.type = 'button';
|
||||
allChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
|
||||
allChannelsBtn.textContent = 'Все каналы';
|
||||
allChannelsBtn.addEventListener('click', () => {
|
||||
if (listState.activeTab === 'feed') return;
|
||||
listState.activeTab = 'feed';
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
topBarEl.append(tabsEl, topActionBtn);
|
||||
const topTitle = document.createElement('strong');
|
||||
topTitle.className = 'channels-top-title';
|
||||
|
||||
const myChannelsBtn = document.createElement('button');
|
||||
myChannelsBtn.type = 'button';
|
||||
myChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
|
||||
myChannelsBtn.textContent = 'Мои каналы';
|
||||
myChannelsBtn.addEventListener('click', () => {
|
||||
if (listState.activeTab === 'my') return;
|
||||
listState.activeTab = 'my';
|
||||
rerenderList();
|
||||
});
|
||||
|
||||
const createInMyBtn = document.createElement('button');
|
||||
createInMyBtn.type = 'button';
|
||||
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
||||
createInMyBtn.textContent = '+';
|
||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||||
|
||||
topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle, myChannelsBtn);
|
||||
topBarEl.append(topBarLeft, createInMyBtn);
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
@@ -1229,8 +1247,19 @@ export function render({ navigate, route }) {
|
||||
refreshFeed: reloadFeed,
|
||||
});
|
||||
|
||||
const showCreate = !isGuest && listState.activeTab === 'my';
|
||||
topActionBtn.style.display = showCreate ? '' : 'none';
|
||||
if (listState.activeTab === 'my' && !isGuest) {
|
||||
backToFeedBtn.style.display = '';
|
||||
allChannelsBtn.style.display = '';
|
||||
myChannelsBtn.style.display = 'none';
|
||||
topTitle.textContent = 'Мои каналы';
|
||||
createInMyBtn.style.display = '';
|
||||
} else {
|
||||
backToFeedBtn.style.display = 'none';
|
||||
allChannelsBtn.style.display = 'none';
|
||||
myChannelsBtn.style.display = isGuest ? 'none' : '';
|
||||
topTitle.textContent = 'Каналы';
|
||||
createInMyBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
updateBottomCta({
|
||||
button: bottomCta,
|
||||
@@ -1238,10 +1267,6 @@ export function render({ navigate, route }) {
|
||||
navigate,
|
||||
isTabEmpty,
|
||||
});
|
||||
tabsEl.querySelectorAll('.channels-tab-btn').forEach((btn, idx) => {
|
||||
const key = TAB_ORDER[idx];
|
||||
btn.classList.toggle('is-active', key === listState.activeTab);
|
||||
});
|
||||
};
|
||||
|
||||
let touchStartX = 0;
|
||||
@@ -1262,6 +1287,7 @@ export function render({ navigate, route }) {
|
||||
if (index < 0) return;
|
||||
if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1];
|
||||
if (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1];
|
||||
if (isGuest && listState.activeTab === 'my') listState.activeTab = 'feed';
|
||||
rerenderList();
|
||||
}, { passive: true });
|
||||
|
||||
|
||||
@@ -102,14 +102,46 @@ export function render({ navigate }) {
|
||||
nextButton.addEventListener('click', async () => {
|
||||
formError.style.display = 'none';
|
||||
const isFree = await runAvailabilityCheck();
|
||||
if (!isFree) {
|
||||
return;
|
||||
}
|
||||
if (!isFree) return;
|
||||
|
||||
state.registrationDraft.login = loginInput.value.trim();
|
||||
state.registrationDraft.password = passwordInput.value;
|
||||
state.registrationDraft.preGeneratedKeyBundle = null;
|
||||
|
||||
navigate('registration-payment-view');
|
||||
// Показываем информационный экран пока генерируются ключи
|
||||
form.innerHTML = '';
|
||||
const infoMsg = document.createElement('p');
|
||||
infoMsg.className = 'auth-copy';
|
||||
infoMsg.textContent =
|
||||
'Из вашего логина и пароля (надеемся, что вы выбрали достаточно длинный и надёжный пароль) ' +
|
||||
'генерируется секрет, из которого получаются root key, blockchain key и device key.';
|
||||
|
||||
const spinnerMsg = document.createElement('p');
|
||||
spinnerMsg.className = 'meta-muted';
|
||||
spinnerMsg.textContent = 'Генерация ключей...';
|
||||
|
||||
const genError = document.createElement('p');
|
||||
genError.className = 'status-line is-unavailable';
|
||||
genError.style.display = 'none';
|
||||
|
||||
form.append(infoMsg, spinnerMsg, genError);
|
||||
nextButton.disabled = true;
|
||||
backButton.disabled = true;
|
||||
|
||||
try {
|
||||
const keyBundle = await authService.derivePasswordKeyBundle(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.password,
|
||||
);
|
||||
state.registrationDraft.preGeneratedKeyBundle = keyBundle;
|
||||
navigate('registration-payment-view');
|
||||
} catch (error) {
|
||||
genError.textContent = `Ошибка генерации ключей: ${error?.message || 'неизвестная ошибка'}`;
|
||||
genError.style.display = '';
|
||||
spinnerMsg.style.display = 'none';
|
||||
nextButton.disabled = false;
|
||||
backButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(backButton, nextButton);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.js';
|
||||
import { bytesToBase64 } from '../services/crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from '../services/device-key-utils.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-draft-keys-view', title: 'Сгенерированные ключи', showAppChrome: false };
|
||||
|
||||
function makeSecretField({ label, value }) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'field-label';
|
||||
labelEl.textContent = label;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'inline-input-row';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input';
|
||||
input.type = 'password';
|
||||
input.readOnly = true;
|
||||
input.value = value;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'ghost-btn';
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.textContent = 'Показать';
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
toggleBtn.textContent = 'Скрыть';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
toggleBtn.textContent = 'Показать';
|
||||
}
|
||||
});
|
||||
|
||||
row.append(input, toggleBtn);
|
||||
wrap.append(labelEl, row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function makePublicField({ label, value }) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'field-label';
|
||||
labelEl.textContent = label;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input';
|
||||
input.type = 'text';
|
||||
input.readOnly = true;
|
||||
input.value = value;
|
||||
|
||||
wrap.append(labelEl, input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
|
||||
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
if (!keyBundle) {
|
||||
const msg = document.createElement('p');
|
||||
msg.className = 'status-line is-unavailable';
|
||||
msg.textContent = 'Ключи ещё не сгенерированы. Вернитесь на экран регистрации и введите логин с паролем.';
|
||||
card.append(msg);
|
||||
} else {
|
||||
const warning = document.createElement('p');
|
||||
warning.className = 'meta-muted';
|
||||
warning.textContent =
|
||||
'Никому не сообщайте приватные ключи и секрет. Они не хранятся на сервере и существуют только на вашем устройстве.';
|
||||
|
||||
card.append(warning);
|
||||
|
||||
// Секрет (root key seed)
|
||||
let secretB64 = '';
|
||||
try {
|
||||
const rootSeed32 = extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64);
|
||||
secretB64 = bytesToBase64(rootSeed32);
|
||||
} catch {
|
||||
secretB64 = '(не удалось извлечь)';
|
||||
}
|
||||
card.append(makeSecretField({ label: 'Секрет (root seed, 32 байта)', value: secretB64 }));
|
||||
|
||||
// Root key
|
||||
const rootSep = document.createElement('p');
|
||||
rootSep.className = 'field-label';
|
||||
rootSep.textContent = 'Root key';
|
||||
card.append(rootSep);
|
||||
card.append(makePublicField({ label: 'Root — публичный', value: keyBundle.rootPair.publicKeyB64 }));
|
||||
card.append(makeSecretField({ label: 'Root — приватный (PKCS8)', value: keyBundle.rootPair.privatePkcs8B64 }));
|
||||
|
||||
// Blockchain key
|
||||
const bchSep = document.createElement('p');
|
||||
bchSep.className = 'field-label';
|
||||
bchSep.textContent = 'Blockchain key';
|
||||
card.append(bchSep);
|
||||
card.append(makePublicField({ label: 'Blockchain — публичный', value: keyBundle.blockchainPair.publicKeyB64 }));
|
||||
card.append(makeSecretField({ label: 'Blockchain — приватный (PKCS8)', value: keyBundle.blockchainPair.privatePkcs8B64 }));
|
||||
|
||||
// Device key
|
||||
const devSep = document.createElement('p');
|
||||
devSep.className = 'field-label';
|
||||
devSep.textContent = 'Device key (= Solana wallet)';
|
||||
card.append(devSep);
|
||||
card.append(makePublicField({ label: 'Device — публичный', value: keyBundle.devicePair.publicKeyB64 }));
|
||||
card.append(makeSecretField({ label: 'Device — приватный (PKCS8)', value: keyBundle.devicePair.privatePkcs8B64 }));
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'ghost-btn';
|
||||
backButton.type = 'button';
|
||||
backButton.textContent = 'Назад';
|
||||
backButton.addEventListener('click', () => navigate('registration-payment-view'));
|
||||
|
||||
actions.append(backButton);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Сгенерированные ключи',
|
||||
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
|
||||
}),
|
||||
card,
|
||||
actions,
|
||||
);
|
||||
|
||||
return screen;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getBalanceSol,
|
||||
getTopupSiteUrl,
|
||||
} from '../services/solana-wallet-service.js';
|
||||
import { registerUserOnSolana } from '../services/solana-register-service.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
||||
const MIN_REQUIRED_SOL = 0.01;
|
||||
@@ -136,6 +137,12 @@ export function render({ navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
const showKeysButton = document.createElement('button');
|
||||
showKeysButton.className = 'ghost-btn';
|
||||
showKeysButton.type = 'button';
|
||||
showKeysButton.textContent = 'Показать сгенерированные ключи';
|
||||
showKeysButton.addEventListener('click', () => navigate('registration-draft-keys-view'));
|
||||
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.className = 'primary-btn';
|
||||
submitButton.type = 'button';
|
||||
@@ -143,8 +150,8 @@ export function render({ navigate }) {
|
||||
submitButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
|
||||
const cryptoState = getCryptoRuntimeState();
|
||||
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
|
||||
const cryptoState = getCryptoRuntimeState();
|
||||
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
|
||||
status.style.display = '';
|
||||
@@ -169,6 +176,33 @@ export function render({ navigate }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Используем предсгенерированный keyBundle или генерируем заново
|
||||
let keyBundle = state.registrationDraft.preGeneratedKeyBundle;
|
||||
if (!keyBundle) {
|
||||
keyBundle = await authService.derivePasswordKeyBundle(
|
||||
state.registrationDraft.login,
|
||||
state.registrationDraft.password,
|
||||
);
|
||||
}
|
||||
|
||||
// Регистрация на Solana (смарт контракт)
|
||||
submitButton.textContent = 'Регистрация в Solana...';
|
||||
try {
|
||||
await registerUserOnSolana({
|
||||
login: state.registrationDraft.login,
|
||||
keyBundle,
|
||||
solanaEndpoint: state.entrySettings.solanaServer,
|
||||
});
|
||||
} catch (solanaError) {
|
||||
const solanaMsg = String(solanaError?.message || '');
|
||||
// Пользователь уже зарегистрирован в Solana — продолжаем
|
||||
if (!solanaMsg.includes('already') && !solanaMsg.includes('UserAlreadyExists')) {
|
||||
throw new Error(`Ошибка регистрации в Solana: ${solanaMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Регистрация на сервере SHiNE
|
||||
submitButton.textContent = 'Регистрация на сервере...';
|
||||
await authService.reconnect(state.entrySettings.shineServer);
|
||||
const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password);
|
||||
state.registrationDraft.flowType = 'registration';
|
||||
@@ -200,7 +234,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
card.children[1].append(walletRow);
|
||||
card.children[2].append(balanceRow);
|
||||
card.append(topupButton, submitButton, status);
|
||||
card.append(topupButton, showKeysButton, submitButton, status);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
|
||||
@@ -7,6 +7,7 @@ export const PRE_AUTH_PAGES = [
|
||||
'entry-settings-view',
|
||||
'register-view',
|
||||
'registration-payment-view',
|
||||
'registration-draft-keys-view',
|
||||
'registration-keys-view',
|
||||
'topup-view',
|
||||
'login-view',
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { sha256Bytes, signBytes, importPkcs8Ed25519, base64ToBytes } from './crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from './device-key-utils.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 ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
|
||||
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
|
||||
|
||||
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 = v >>> 0;
|
||||
buf.push(n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF);
|
||||
}
|
||||
|
||||
function pushU64LE(buf, bigV) {
|
||||
const b = typeof bigV === 'bigint' ? bigV : BigInt(bigV);
|
||||
const lo = Number(b & 0xFFFFFFFFn) >>> 0;
|
||||
const hi = Number((b >> 32n) & 0xFFFFFFFFn) >>> 0;
|
||||
pushU32LE(buf, lo);
|
||||
pushU32LE(buf, hi);
|
||||
}
|
||||
|
||||
class BorshBuf {
|
||||
constructor() { this._b = []; }
|
||||
u8(v) { this._b.push(v & 0xFF); }
|
||||
u32(v) { pushU32LE(this._b, v); }
|
||||
u64(v) { pushU64LE(this._b, v); }
|
||||
bool(v) { this.u8(v ? 1 : 0); }
|
||||
bytes32(b) { for (const x of b) this._b.push(x); }
|
||||
vecU8(b) { this.u32(b.length); for (const x of b) this._b.push(x); }
|
||||
str(s) {
|
||||
const enc = new TextEncoder().encode(s);
|
||||
this.u32(enc.length);
|
||||
for (const x of enc) this._b.push(x);
|
||||
}
|
||||
vecStr(arr) {
|
||||
this.u32(arr.length);
|
||||
for (const s of arr) this.str(s);
|
||||
}
|
||||
raw(bytes) { for (const x of bytes) this._b.push(x); }
|
||||
result() { return new Uint8Array(this._b); }
|
||||
}
|
||||
|
||||
// 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.bytes32(new Uint8Array(32)); // server_key (default)
|
||||
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();
|
||||
}
|
||||
|
||||
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: Buffer.from(buildEd25519IxData(rootSig64, rootKey32, unsignedHash)),
|
||||
});
|
||||
const ed25519BchIx = new solana.TransactionInstruction({
|
||||
programId: ed25519Program,
|
||||
keys: [],
|
||||
data: Buffer.from(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: Buffer.from(ixData),
|
||||
});
|
||||
|
||||
const sig = await solana.sendAndConfirmTransaction(
|
||||
connection,
|
||||
new solana.Transaction().add(ed25519RootIx, ed25519BchIx, createUserIx),
|
||||
[deviceKeypair],
|
||||
{ commitment: 'confirmed' },
|
||||
);
|
||||
|
||||
return { signature: sig, blockchainName };
|
||||
}
|
||||
@@ -252,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
storagePwd: '',
|
||||
pendingKeyBundle: null,
|
||||
pendingSessionMaterial: null,
|
||||
preGeneratedKeyBundle: null,
|
||||
},
|
||||
loginDraft: {
|
||||
login: storedSession?.login || '',
|
||||
|
||||
@@ -2742,6 +2742,37 @@ textarea.input {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.channels-top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channels-top-title {
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channels-top-switch-btn {
|
||||
min-height: 36px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channels-top-back-btn,
|
||||
.channels-top-add-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.channels-top-action-btn {
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
@@ -3794,6 +3825,13 @@ textarea.input {
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.channels-screen--list .channels-top-bar {
|
||||
margin-left: 24px;
|
||||
margin-right: 24px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.channels-screen--list .channel-row {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
Reference in New Issue
Block a user