UI: обновлена шапка каналов, закрыты pending-задачи и обновлены версии
This commit is contained in:
parent
8941582d54
commit
f1cfe9b6aa
@ -36,6 +36,7 @@
|
|||||||
|
|
||||||
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
|
- `medium/2026-05-24_1140_репосты_в_каналах_и_тредах.md` - репосты в каналах и тредах.
|
||||||
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
|
- `medium/2026-05-25_1106_shine_balance_wallet.md` - кошелёк и пополнение баланса сияния через блокчейн.
|
||||||
|
- `medium/2026-05-26_0029_esp32s3_file_storage.md` - ESP32S3 как личное файловое хранилище SHiNE для файлов переписок и вложений.
|
||||||
|
|
||||||
### Дальнее будущее
|
### Дальнее будущее
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,44 @@
|
|||||||
|
# ESP32S3 как личное файловое хранилище SHiNE
|
||||||
|
|
||||||
|
## Горизонт
|
||||||
|
|
||||||
|
Среднесрочный: ближайшие недели или 1-2 месяца.
|
||||||
|
|
||||||
|
## Зачем нужна фича
|
||||||
|
|
||||||
|
Нужно проработать маленький физический сервер на ESP32S3 как персональное или доверенное файловое хранилище SHiNE.
|
||||||
|
|
||||||
|
Идея: при обмене сообщениями пользователи смогут использовать такой сервер для хранения своих файлов, вложений, файлов общих переписок и связанных данных.
|
||||||
|
|
||||||
|
## Что нужно сделать
|
||||||
|
|
||||||
|
- Описать роль ESP32S3-сервера в общей архитектуре ключей и сессий.
|
||||||
|
- Определить, какие ключи может хранить такое устройство.
|
||||||
|
- Решить, хранит ли устройство только файлы или также подписывает пользовательские операции.
|
||||||
|
- Описать протокол загрузки, скачивания и удаления файлов.
|
||||||
|
- Определить правила шифрования файлов до отправки на устройство.
|
||||||
|
- Продумать индексацию файлов для личных и общих переписок.
|
||||||
|
- Решить, как устройство авторизуется на основном сервере SHiNE.
|
||||||
|
|
||||||
|
## Вопросы перед реализацией
|
||||||
|
|
||||||
|
- ESP32S3 должен работать как полностью локальное устройство или как публично доступный мини-сервер?
|
||||||
|
- Нужен ли внешний relay, если устройство находится за NAT?
|
||||||
|
- Какие ограничения по размеру файла считаем допустимыми?
|
||||||
|
- Хранит ли устройство метаданные переписок или только зашифрованные blob-файлы?
|
||||||
|
- Как восстанавливать доступ, если устройство потеряно или заменено?
|
||||||
|
|
||||||
|
## Что уже сделано
|
||||||
|
|
||||||
|
Код не реализован. Идея зафиксирована как будущая задача после описания модели ключей.
|
||||||
|
|
||||||
|
## Документы, которые нужно обновить при возврате
|
||||||
|
|
||||||
|
- `Dev_Docs/Keys/README.md`
|
||||||
|
- `Dev_Docs/Personal_Messages/README.md`
|
||||||
|
- `Dev_Docs/API/`
|
||||||
|
- `Dev_Docs/Blockchain/`, если появятся новые блоки или команды для файлов.
|
||||||
|
|
||||||
|
## С какого места продолжать
|
||||||
|
|
||||||
|
Начать с короткого протокольного документа: роли устройства, авторизация, шифрование файлов, минимальные API-операции и сценарии восстановления.
|
||||||
175
Dev_Docs/Keys/README.md
Normal file
175
Dev_Docs/Keys/README.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# Ключи SHiNE
|
||||||
|
|
||||||
|
Этот документ описывает роли ключей в SHiNE и их связь с Solana, персональным блокчейном, личными сообщениями, сессиями и будущими аппаратными устройствами.
|
||||||
|
|
||||||
|
Документ является архитектурной справкой. Он не меняет текущие форматы API, DM-блоков или блокчейна сам по себе.
|
||||||
|
|
||||||
|
## Коротко
|
||||||
|
|
||||||
|
В SHiNE у пользователя есть несколько уровней ключей:
|
||||||
|
|
||||||
|
- `root key` - главный корневой ключ пользователя, он же основной Solana-ключ.
|
||||||
|
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
|
||||||
|
- `device key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
|
||||||
|
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
|
||||||
|
|
||||||
|
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
|
||||||
|
|
||||||
|
## `root key`
|
||||||
|
|
||||||
|
`root key` - главный ключ пользователя.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- регистрация пользователя в Solana;
|
||||||
|
- создание и обновление пользовательской PDA-записи;
|
||||||
|
- вызов критически важных Solana-функций;
|
||||||
|
- изменение главных настроек пользователя;
|
||||||
|
- управление остальными ключами;
|
||||||
|
- подтверждение операций, которые должны иметь максимальный уровень доверия.
|
||||||
|
|
||||||
|
В текущей модели `root key` совпадает по смыслу с главным Solana-ключом пользователя.
|
||||||
|
|
||||||
|
На `root key` могут храниться значимые средства, если пользователь сознательно выбирает такую модель. Для мелких текущих расходов предпочтительнее использовать `device key`.
|
||||||
|
|
||||||
|
## `blockchain key`
|
||||||
|
|
||||||
|
`blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- подпись записей в персональном блокчейне пользователя;
|
||||||
|
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
|
||||||
|
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи.
|
||||||
|
|
||||||
|
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
|
||||||
|
|
||||||
|
- `username-001` - первая ветка;
|
||||||
|
- `username-002` - вторая ветка;
|
||||||
|
- `username-003` - третья ветка.
|
||||||
|
|
||||||
|
Рабочая логика по умолчанию должна использовать последнюю актуальную ветку. Старые ветки остаются читаемыми и показывают историю смены ключей.
|
||||||
|
|
||||||
|
## `device key`
|
||||||
|
|
||||||
|
`device key` - общий ключ, который знают доверенные устройства пользователя.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- повседневные входящие и исходящие личные сообщения;
|
||||||
|
- звонки и связанные с ними сообщения;
|
||||||
|
- self-messages, то есть внутренние сообщения пользователя самому себе;
|
||||||
|
- мелкие Solana-расходы на текущие операции;
|
||||||
|
- derivation Arweave-кошелька;
|
||||||
|
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
|
||||||
|
|
||||||
|
Arweave-кошелёк должен выводиться из `device key` по протоколу:
|
||||||
|
|
||||||
|
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md`
|
||||||
|
|
||||||
|
Если пользователь теряет только `device key`, в худшем случае ломается повседневная переписка и доступ конкретных устройств к ежедневным операциям. `root key` и `blockchain key` при правильной архитектуре остаются отдельно защищёнными.
|
||||||
|
|
||||||
|
## `session key`
|
||||||
|
|
||||||
|
`session key` - уникальный ключ конкретной сессии или устройства.
|
||||||
|
|
||||||
|
Возможные форматы:
|
||||||
|
|
||||||
|
- `Ed25519` - предпочтительный современный вариант;
|
||||||
|
- `RSA` - legacy-вариант, полезный для устройств, где системное защищённое хранилище хорошо поддерживает RSA-ключи и не позволяет извлекать приватный ключ.
|
||||||
|
|
||||||
|
Назначение:
|
||||||
|
|
||||||
|
- авторизация сессии на сервере;
|
||||||
|
- привязка устройства к пользователю;
|
||||||
|
- подтверждение запросов от конкретной сессии;
|
||||||
|
- доступ к зашифрованному `device key` после успешной авторизации.
|
||||||
|
|
||||||
|
Одна и та же сессия может быть пригодна для подключения к нескольким серверам пользователя, если архитектура конкретного пользователя это допускает.
|
||||||
|
|
||||||
|
У сессии должны быть:
|
||||||
|
|
||||||
|
- имя сессии;
|
||||||
|
- тип сессии;
|
||||||
|
- публичная часть ключа;
|
||||||
|
- ссылка на пользователя;
|
||||||
|
- информация о сервере или серверах, которым эта сессия доверена.
|
||||||
|
|
||||||
|
Имя сессии может создаваться автоматически из названия устройства и короткого случайного идентификатора, например `Android-a1b2c3`, `Ubuntu-f47a90`. Пользователь может переименовать сессию.
|
||||||
|
|
||||||
|
## Типы сессий
|
||||||
|
|
||||||
|
Базовые типы:
|
||||||
|
|
||||||
|
- обычная пользовательская сессия;
|
||||||
|
- серверная сессия;
|
||||||
|
- аппаратная или доверенная сессия с доступом к расширенным ключам.
|
||||||
|
|
||||||
|
Обычное устройство обычно имеет:
|
||||||
|
|
||||||
|
- собственный `session key`;
|
||||||
|
- зашифрованный `device key`, который открывается после авторизации;
|
||||||
|
- доступ к DM, звонкам и обычным пользовательским операциям.
|
||||||
|
|
||||||
|
Доверенное серверное или аппаратное устройство может иметь:
|
||||||
|
|
||||||
|
- `root key`;
|
||||||
|
- `blockchain key`;
|
||||||
|
- `device key`;
|
||||||
|
- собственный `session key`.
|
||||||
|
|
||||||
|
Такая сессия может подписывать операции повышенной важности по запросам пользователя.
|
||||||
|
|
||||||
|
## Внутренние self-messages
|
||||||
|
|
||||||
|
Self-message - это сообщение пользователя самому себе.
|
||||||
|
|
||||||
|
Такие сообщения нужны, чтобы обычное устройство могло попросить доверенное устройство выполнить действие:
|
||||||
|
|
||||||
|
- подписать запись `blockchain key` и передать её в SHiNE-блокчейн;
|
||||||
|
- подписать изменение настройки через `root key`;
|
||||||
|
- обновить ключи;
|
||||||
|
- сохранить внутреннюю команду или настройку;
|
||||||
|
- отправить сообщение другому пользователю с сохранением копии себе;
|
||||||
|
- сохранить сообщение только себе.
|
||||||
|
|
||||||
|
Важно: self-message не является публичной командой сервера. Это пользовательская внутренняя команда, которую сервер или доверенное устройство обрабатывает в рамках прав конкретного пользователя.
|
||||||
|
|
||||||
|
## Шифрование входящих сообщений
|
||||||
|
|
||||||
|
Входящее сообщение может быть зашифровано:
|
||||||
|
|
||||||
|
- `device key`;
|
||||||
|
- `session key`;
|
||||||
|
- отдельным ключом конкретного чата;
|
||||||
|
- другим ключом, который уже известен клиенту.
|
||||||
|
|
||||||
|
В сообщении не должно быть лишнего раскрытия того, каким именно ключом оно зашифровано. Клиент пробует расшифровать сообщение доступными ключами по порядку. Если расшифровка не удалась, сообщение остаётся непонятным для этого устройства.
|
||||||
|
|
||||||
|
## Копии сообщений
|
||||||
|
|
||||||
|
Для отправки сообщений нужны несколько режимов:
|
||||||
|
|
||||||
|
- сообщение другому пользователю с исходящей копией себе;
|
||||||
|
- сообщение другому пользователю без локальной исходящей копии;
|
||||||
|
- сообщение только себе.
|
||||||
|
|
||||||
|
Это должно позволить строить обычные DM, внутренние команды, личные заметки и зашифрованные пользовательские чаты поверх одной общей модели сообщений.
|
||||||
|
|
||||||
|
## Связанные документы
|
||||||
|
|
||||||
|
- `Dev_Docs/Personal_Messages/README.md` - текущая документация личных сообщений.
|
||||||
|
- `Dev_Docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
|
||||||
|
- `Dev_Docs/Solana_Architecture/README.md` - архитектура Solana-программ, PDA-счетов, DAO и движения средств.
|
||||||
|
- `Dev_Docs/Инициализация_Solana_регистрации/README.md` - деплой и первичная инициализация Solana-регистрации.
|
||||||
|
- `Dev_Docs/Протоколы/SHINE_ARWEAVE_DERIVATION_V1.md` - derivation Arweave-кошелька из `device key`.
|
||||||
|
|
||||||
|
## Что нужно уточнить перед реализацией
|
||||||
|
|
||||||
|
- точный формат записи списка ключей в Solana PDA;
|
||||||
|
- как именно обозначать активную ветку персонального блокчейна;
|
||||||
|
- какие операции требуют `root key`, а какие достаточно подписывать `blockchain key`;
|
||||||
|
- формат self-message-команд;
|
||||||
|
- порядок перебора ключей при расшифровке входящих сообщений;
|
||||||
|
- правила ротации `device key` и восстановления доступа после потери устройства;
|
||||||
|
- какие типы серверных и аппаратных сессий нужны в первой реализации.
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.91
|
client.version=1.2.92
|
||||||
server.version=1.2.85
|
server.version=1.2.86
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import * as entrySettingsView from './pages/entry-settings-view.js';
|
|||||||
import * as registerView from './pages/register-view.js';
|
import * as registerView from './pages/register-view.js';
|
||||||
import * as registrationPaymentView from './pages/registration-payment-view.js';
|
import * as registrationPaymentView from './pages/registration-payment-view.js';
|
||||||
import * as registrationKeysView from './pages/registration-keys-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 topupView from './pages/topup-view.js';
|
||||||
import * as loginView from './pages/login-view.js';
|
import * as loginView from './pages/login-view.js';
|
||||||
import * as loginCameraView from './pages/login-camera-view.js';
|
import * as loginCameraView from './pages/login-camera-view.js';
|
||||||
@ -78,6 +79,7 @@ const routes = {
|
|||||||
'register-view': registerView,
|
'register-view': registerView,
|
||||||
'registration-payment-view': registrationPaymentView,
|
'registration-payment-view': registrationPaymentView,
|
||||||
'registration-keys-view': registrationKeysView,
|
'registration-keys-view': registrationKeysView,
|
||||||
|
'registration-draft-keys-view': registrationDraftKeysView,
|
||||||
'topup-view': topupView,
|
'topup-view': topupView,
|
||||||
'login-view': loginView,
|
'login-view': loginView,
|
||||||
'login-camera-view': loginCameraView,
|
'login-camera-view': loginCameraView,
|
||||||
|
|||||||
@ -1173,34 +1173,52 @@ export function render({ navigate, route }) {
|
|||||||
const topBarEl = document.createElement('div');
|
const topBarEl = document.createElement('div');
|
||||||
topBarEl.className = 'channels-top-bar';
|
topBarEl.className = 'channels-top-bar';
|
||||||
|
|
||||||
const tabsEl = document.createElement('div');
|
const topBarLeft = document.createElement('div');
|
||||||
tabsEl.className = 'channels-tabs';
|
topBarLeft.className = 'channels-top-left';
|
||||||
const tabLabels = {
|
|
||||||
feed: 'Каналы',
|
const backToFeedBtn = document.createElement('button');
|
||||||
my: 'Мои каналы',
|
backToFeedBtn.type = 'button';
|
||||||
};
|
backToFeedBtn.className = 'icon-btn channels-top-back-btn';
|
||||||
TAB_ORDER.forEach((tabKey) => {
|
backToFeedBtn.textContent = '←';
|
||||||
if (isGuest && tabKey === 'my') return;
|
backToFeedBtn.setAttribute('aria-label', 'Назад');
|
||||||
const tabBtn = document.createElement('button');
|
backToFeedBtn.addEventListener('click', () => {
|
||||||
tabBtn.type = 'button';
|
if (listState.activeTab === 'feed') return;
|
||||||
tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`;
|
listState.activeTab = 'feed';
|
||||||
tabBtn.textContent = tabLabels[tabKey] || tabKey;
|
rerenderList();
|
||||||
tabBtn.addEventListener('click', () => {
|
|
||||||
if (listState.activeTab === tabKey) return;
|
|
||||||
listState.activeTab = tabKey;
|
|
||||||
rerenderList();
|
|
||||||
});
|
|
||||||
tabsEl.append(tabBtn);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const topActionBtn = document.createElement('button');
|
const allChannelsBtn = document.createElement('button');
|
||||||
topActionBtn.type = 'button';
|
allChannelsBtn.type = 'button';
|
||||||
topActionBtn.className = 'secondary-btn channels-top-action-btn';
|
allChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
|
||||||
topActionBtn.textContent = 'Создать канал';
|
allChannelsBtn.textContent = 'Все каналы';
|
||||||
topActionBtn.addEventListener('click', () => navigate('add-channel-view'));
|
allChannelsBtn.addEventListener('click', () => {
|
||||||
if (isGuest) topActionBtn.style.display = 'none';
|
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');
|
const bottomCta = document.createElement('button');
|
||||||
bottomCta.type = 'button';
|
bottomCta.type = 'button';
|
||||||
@ -1229,8 +1247,19 @@ export function render({ navigate, route }) {
|
|||||||
refreshFeed: reloadFeed,
|
refreshFeed: reloadFeed,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showCreate = !isGuest && listState.activeTab === 'my';
|
if (listState.activeTab === 'my' && !isGuest) {
|
||||||
topActionBtn.style.display = showCreate ? '' : 'none';
|
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({
|
updateBottomCta({
|
||||||
button: bottomCta,
|
button: bottomCta,
|
||||||
@ -1238,10 +1267,6 @@ export function render({ navigate, route }) {
|
|||||||
navigate,
|
navigate,
|
||||||
isTabEmpty,
|
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;
|
let touchStartX = 0;
|
||||||
@ -1262,6 +1287,7 @@ export function render({ navigate, route }) {
|
|||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1];
|
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 (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1];
|
||||||
|
if (isGuest && listState.activeTab === 'my') listState.activeTab = 'feed';
|
||||||
rerenderList();
|
rerenderList();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
|
|||||||
@ -102,14 +102,46 @@ export function render({ navigate }) {
|
|||||||
nextButton.addEventListener('click', async () => {
|
nextButton.addEventListener('click', async () => {
|
||||||
formError.style.display = 'none';
|
formError.style.display = 'none';
|
||||||
const isFree = await runAvailabilityCheck();
|
const isFree = await runAvailabilityCheck();
|
||||||
if (!isFree) {
|
if (!isFree) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.registrationDraft.login = loginInput.value.trim();
|
state.registrationDraft.login = loginInput.value.trim();
|
||||||
state.registrationDraft.password = passwordInput.value;
|
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);
|
actions.append(backButton, nextButton);
|
||||||
|
|||||||
140
shine-UI/js/pages/registration-draft-keys-view.js
Normal file
140
shine-UI/js/pages/registration-draft-keys-view.js
Normal file
@ -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,
|
getBalanceSol,
|
||||||
getTopupSiteUrl,
|
getTopupSiteUrl,
|
||||||
} from '../services/solana-wallet-service.js';
|
} from '../services/solana-wallet-service.js';
|
||||||
|
import { registerUserOnSolana } from '../services/solana-register-service.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
||||||
const MIN_REQUIRED_SOL = 0.01;
|
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');
|
const submitButton = document.createElement('button');
|
||||||
submitButton.className = 'primary-btn';
|
submitButton.className = 'primary-btn';
|
||||||
submitButton.type = 'button';
|
submitButton.type = 'button';
|
||||||
@ -143,8 +150,8 @@ export function render({ navigate }) {
|
|||||||
submitButton.addEventListener('click', async () => {
|
submitButton.addEventListener('click', async () => {
|
||||||
status.style.display = 'none';
|
status.style.display = 'none';
|
||||||
|
|
||||||
const cryptoState = getCryptoRuntimeState();
|
const cryptoState = getCryptoRuntimeState();
|
||||||
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
|
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
|
||||||
status.className = 'status-line is-unavailable';
|
status.className = 'status-line is-unavailable';
|
||||||
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
|
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
|
||||||
status.style.display = '';
|
status.style.display = '';
|
||||||
@ -169,6 +176,33 @@ export function render({ navigate }) {
|
|||||||
return;
|
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);
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password);
|
const result = await authService.registerUser(state.registrationDraft.login, state.registrationDraft.password);
|
||||||
state.registrationDraft.flowType = 'registration';
|
state.registrationDraft.flowType = 'registration';
|
||||||
@ -200,7 +234,7 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
card.children[1].append(walletRow);
|
card.children[1].append(walletRow);
|
||||||
card.children[2].append(balanceRow);
|
card.children[2].append(balanceRow);
|
||||||
card.append(topupButton, submitButton, status);
|
card.append(topupButton, showKeysButton, submitButton, status);
|
||||||
|
|
||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const PRE_AUTH_PAGES = [
|
|||||||
'entry-settings-view',
|
'entry-settings-view',
|
||||||
'register-view',
|
'register-view',
|
||||||
'registration-payment-view',
|
'registration-payment-view',
|
||||||
|
'registration-draft-keys-view',
|
||||||
'registration-keys-view',
|
'registration-keys-view',
|
||||||
'topup-view',
|
'topup-view',
|
||||||
'login-view',
|
'login-view',
|
||||||
|
|||||||
281
shine-UI/js/services/solana-register-service.js
Normal file
281
shine-UI/js/services/solana-register-service.js
Normal file
@ -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: '',
|
storagePwd: '',
|
||||||
pendingKeyBundle: null,
|
pendingKeyBundle: null,
|
||||||
pendingSessionMaterial: null,
|
pendingSessionMaterial: null,
|
||||||
|
preGeneratedKeyBundle: null,
|
||||||
},
|
},
|
||||||
loginDraft: {
|
loginDraft: {
|
||||||
login: storedSession?.login || '',
|
login: storedSession?.login || '',
|
||||||
|
|||||||
@ -2742,6 +2742,37 @@ textarea.input {
|
|||||||
gap: 8px;
|
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 {
|
.channels-top-action-btn {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@ -3794,6 +3825,13 @@ textarea.input {
|
|||||||
margin-right: 24px;
|
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 {
|
.channels-screen--list .channel-row {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user