GPT: сгенерено и не проверено (смена ключей пользователя)

This commit is contained in:
AidarKC
2026-09-27 15:50:27 +03:00
parent 13693a0a53
commit 151a2c1754
94 changed files with 5327 additions and 815 deletions
+22 -21
View File
@@ -177,14 +177,14 @@ function createPasswordModal() {
<div class="stack" style="gap:0.45rem;">
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="once" checked />
<span>Использовать главный ключ только сейчас</span>
<span>Использовать blockchain key только сейчас</span>
</label>
<label class="checkbox-row">
<input type="radio" name="access-servers-key-mode" value="save" />
<span>Сохранить главный ключ на этом устройстве</span>
<span>Сохранить blockchain key на этом устройстве</span>
</label>
</div>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, blockchain key попадёт в зашифрованный контейнер устройства.</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="access-servers-password-cancel">Отмена</button>
<button class="primary-btn" type="button" id="access-servers-password-confirm">Продолжить</button>
@@ -247,7 +247,7 @@ function createPasswordModal() {
const mode = root.querySelector('input[name="access-servers-key-mode"]:checked');
close({
password,
saveRoot: mode instanceof HTMLInputElement && mode.value === 'save',
saveBlockchain: mode instanceof HTMLInputElement && mode.value === 'save',
});
});
window.setTimeout(() => inputEl.focus(), 0);
@@ -482,48 +482,49 @@ export function render({navigate, chrome}) {
saved = null;
}
const savedRoot = String(saved?.rootKey || '').trim();
const savedBlockchain = String(saved?.blockchainKey || '').trim();
const savedClient = String(saved?.clientKey || '').trim();
if (savedRoot && savedClient) {
if (savedBlockchain && savedClient) {
return {
rootPrivatePkcs8B64: savedRoot,
blockchainPrivatePkcs8B64: savedBlockchain,
clientPrivatePkcs8B64: savedClient,
clientAddress: await clientAddressFromPrivatePkcs8(savedClient),
payerAddress: await clientAddressFromPrivatePkcs8(savedBlockchain),
};
}
const passwordResult = await passwordModal?.open({
title: 'Нужен пароль для обновления серверов доступа',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
text: 'Чтобы изменить сервер доступа, нужно подписать обновление PDA текущим blockchain key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
note: savedClient
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен blockchain key.'
: 'На устройстве не хватает blockchain key и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
});
if (!passwordResult) {
throw new Error('Операция отменена пользователем.');
}
const keyBundle = await authService.derivePasswordKeyBundle(sessionLogin, passwordResult.password);
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
const derivedBlockchainPublic = base64ToBytes(keyBundle.blockchainPair.publicKeyB64);
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
const activeBlockchain = currentPda.forks?.at(-1)?.blockchainKey;
if (!activeBlockchain || !equalBytes(derivedBlockchainPublic, activeBlockchain)) {
throw new Error('Пароль не подходит: blockchain key не совпал с активным fork аккаунта.');
}
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
}
if (passwordResult.saveRoot) {
if (passwordResult.saveBlockchain) {
await authService.persistSelectedKeys(sessionLogin, storagePwd, keyBundle, {
saveRoot: true,
saveBlockchain: false,
saveRoot: false,
saveBlockchain: true,
});
}
return {
rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64,
blockchainPrivatePkcs8B64: keyBundle.blockchainPair.privatePkcs8B64,
clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64,
clientAddress: clientAddressFromPublicB64(keyBundle.clientPair.publicKeyB64),
payerAddress: clientAddressFromPublicB64(keyBundle.blockchainPair.publicKeyB64),
};
};
@@ -550,7 +551,7 @@ export function render({navigate, chrome}) {
const tx = await updateShineUserPdaOnSolana({
login: sessionLogin,
solanaEndpoint,
rootPrivatePkcs8B64: signingMaterial.rootPrivatePkcs8B64,
blockchainPrivatePkcs8B64: signingMaterial.blockchainPrivatePkcs8B64,
clientPrivatePkcs8B64: signingMaterial.clientPrivatePkcs8B64,
accessServers: normalizedList,
});
@@ -562,7 +563,7 @@ export function render({navigate, chrome}) {
refreshAddButton();
} catch (error) {
if (isInsufficientFundsForRentError(error)) {
showTopupRequiredStatus(target, signingMaterial?.clientAddress);
showTopupRequiredStatus(target, signingMaterial?.payerAddress);
} else {
target.textContent = error?.message || 'Не удалось обновить сервер доступа.';
}
+4 -27
View File
@@ -14,7 +14,7 @@ import {
formatSol,
getBalanceSol,
getSolanaWalletFromStoredSecret,
getWalletFromStoredClientKey,
getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64,
transferSol,
@@ -138,9 +138,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
if (!root) return;
const recipientChoices = [
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
].filter(Boolean);
root.innerHTML = `
@@ -166,10 +164,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-support-sender-key">
<option value="client-key">Client key</option>
<option value="root-key">Root key</option>
</select>
<div class="channel-support-address" id="channel-support-sender-key">Blockchain key</div>
<div class="channel-support-address" id="channel-support-sender-address">—</div>
<div class="channel-support-balance-row">
@@ -247,31 +242,13 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
};
const resolveSenderWallet = async () => {
const keyId = String(senderSelect?.value || 'client-key');
const keyId = 'blockchain-key';
if (walletCache.has(keyId)) return walletCache.get(keyId);
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
let wallet;
if (keyId === 'root-key') {
try {
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
} catch {
const password = window.prompt(
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
'',
);
if (password == null) throw new Error('Операция отменена.');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
}
} else {
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
}
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
walletCache.set(keyId, wallet);
return wallet;
+4 -27
View File
@@ -6,7 +6,7 @@ import {
formatSol,
getBalanceSol,
getSolanaWalletFromStoredSecret,
getWalletFromStoredClientKey,
getWalletFromStoredBlockchainKey,
getWalletFromStoredRootKey,
solanaAddressFromPublicKeyBase64,
transferSol,
@@ -140,9 +140,7 @@ export function render({ navigate, route, chrome }) {
const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
const channelName = String(channel?.channelName || selector?.channelName || '').trim();
const recipientChoices = [
publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'),
publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'),
publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'),
].filter(Boolean);
content.innerHTML = `
@@ -169,10 +167,7 @@ export function render({ navigate, route, chrome }) {
<section class="channel-donate-section">
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-donate-sender-key">
<option value="client-key">Client key</option>
<option value="root-key">Root key</option>
</select>
<div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
<div class="channel-donate-balance-row">
@@ -233,31 +228,13 @@ export function render({ navigate, route, chrome }) {
};
const resolveSenderWallet = async () => {
const keyId = String(senderSelect?.value || 'client-key');
const keyId = 'blockchain-key';
if (walletCache.has(keyId)) return walletCache.get(keyId);
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
let wallet;
if (keyId === 'root-key') {
try {
wallet = await getWalletFromStoredRootKey({ login, storagePwd });
} catch {
const password = window.prompt(
'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:',
'',
);
if (password == null) throw new Error('Операция отменена.');
const keyBundle = await authService.derivePasswordKeyBundle(login, password);
const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim();
if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.');
wallet = await getSolanaWalletFromStoredSecret(rootPrivate);
}
} else {
wallet = await getWalletFromStoredClientKey({ login, storagePwd });
}
const wallet = await getWalletFromStoredBlockchainKey({ login, storagePwd });
walletCache.set(keyId, wallet);
return wallet;
+67
View File
@@ -0,0 +1,67 @@
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { bytesToBase64 } from '../services/crypto-utils.js';
import { readShineUserPda } from '../services/shine-user-pda-service.js';
import { KeyRotationClient, KEY_ROTATION_REASONS } from '../services/key-rotation-service.js';
export const pageMeta = { id:'key-rotation-view', title:'Смена ключей', hideToolbar:true };
const POLL_MS=1800;
function h(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function short(v){const s=String(v||'');return s.length>20?`${s.slice(0,10)}…${s.slice(-8)}`:s;}
function stageText(s){return ({COPYING_CHAIN:'Копирование новой цепочки',CHAIN_READY:'Новая цепочка готова',ROTATING_PDA:'Ожидание Solana',PDA_ROTATED:'PDA обновлена',REBUILDING_SERVER:'Перестройка сервера',WALLET_MIGRATION:'Перенос средств',MESSAGE_MIGRATION:'Перешифрование сообщений',FINALIZING:'Завершение',COMPLETE:'Готово',NONE:'Новая ротация'})[s]||s;}
function input(label,type='password'){const wrap=document.createElement('label');wrap.className='stack';const t=document.createElement('span');t.className='field-label';t.textContent=label;const el=document.createElement('input');el.className='text-input';el.type=type;wrap.append(t,el);return {wrap,el};}
export function render({navigate,chrome}){
const screen=document.createElement('section');screen.className='stack';
chrome?.setTopbar(createTopBar({title:'Смена ключей',back:{label:'←',onClick:()=>navigate('my-blockchain-view')}}));
const root=document.createElement('div');root.className='stack';screen.append(root);
const client=new KeyRotationClient(authService);let disposed=false;let timer=null;let cachedOldBundle=null;let cachedNewBundle=null;
const login=String(state.session.login||'').trim(); const storagePwd=String(state.session.storagePwdInMemory||'').trim();
const solanaEndpoint=String(state.entrySettings.solanaServer||'').trim();
async function derive(password){return authService.derivePasswordKeyBundle(login,password,{onProgress:()=>{}});}
async function verifyOldPassword(bundle){
const pda=await readShineUserPda({login,solanaEndpoint});
if(bundle.rootPair.publicKeyB64!==bytesToBase64(pda.rootKey) || bundle.blockchainPair.publicKeyB64!==bytesToBase64(pda.blockchain.blockchainPublicKey) || bundle.clientPair.publicKeyB64!==bytesToBase64(pda.clientKey)) throw new Error('Текущий пароль не соответствует ключам PDA');
}
function setRoot(...nodes){root.replaceChildren(...nodes);}
function statusCard(s){const card=document.createElement('div');card.className='card stack';card.innerHTML=`<strong>${h(stageText(s.rotationStatus))}</strong><span class="meta-muted">${h(s.sourceBlockchainName||'')} → ${h(s.candidateBlockchainName||'')}</span><span class="meta-muted">Прогресс: ${Number(s.progressCurrent||0)} / ${Number(s.progressTotal||0)}</span>${s.lastError?`<span>${h(s.lastError)}</span>`:''}`;return card;}
function note(text){const c=document.createElement('div');c.className='card';c.textContent=text;return c;}
async function renderNone(){
const head=note('Выберите последний блок, которому вы доверяете. Всё до него будет точно перепубликовано новым blockchain key, после чего добавится TECH_FORK.');
const oldP=input('Текущий пароль'); const newP=input('Новый пароль'); const new2=input('Повторите новый пароль');
const reason=document.createElement('select');reason.className='text-input';for(const r of KEY_ROTATION_REASONS){const o=document.createElement('option');o.value=String(r.code);o.textContent=r.label;reason.append(o);}
const reasonWrap=document.createElement('label');reasonWrap.className='stack';reasonWrap.innerHTML='<span class="field-label">Причина</span>';reasonWrap.append(reason);
const comment=document.createElement('textarea');comment.className='text-input';comment.rows=3;comment.maxLength=1024;comment.placeholder='Необязательный комментарий для истории';
const list=document.createElement('div');list.className='stack';const more=document.createElement('button');more.className='secondary-btn';more.type='button';more.textContent='Показать более ранние блоки';
let selected=null,before=null,loading=false;
async function load(){if(loading)return;loading=true;more.disabled=true;try{const d=await client.getMyBlockchain({beforeBlock:before,limit:100});for(const b of d.blocks||[]){const btn=document.createElement('button');btn.type='button';btn.className='nav-row';btn.innerHTML=`<span class="nav-row__label">#${b.blockNumber} · ${new Date(Number(b.timestampMs)||0).toLocaleString('ru-RU')}</span><span class="nav-row__hint">${short(b.blockHash)}</span>`;btn.addEventListener('click',()=>{selected=b;list.querySelectorAll('button').forEach(x=>x.setAttribute('aria-pressed','false'));btn.setAttribute('aria-pressed','true');});if(selected==null && b.blockNumber===d.tipBlockNumber){selected=b;btn.setAttribute('aria-pressed','true');}list.append(btn);}before=d.nextBeforeBlock;more.hidden=before==null;}finally{loading=false;more.disabled=false;}}
more.addEventListener('click',()=>void load());
const start=document.createElement('button');start.className='primary-btn';start.type='button';start.textContent='Начать смену ключей';
const error=document.createElement('div');error.className='meta-muted';
start.addEventListener('click',async()=>{start.disabled=true;error.textContent='';try{if(!selected)throw new Error('Выберите последний доверенный блок');if(!oldP.el.value||!newP.el.value)throw new Error('Введите текущий и новый пароль');if(newP.el.value!==new2.el.value)throw new Error('Новые пароли не совпадают');if(oldP.el.value===newP.el.value)throw new Error('Новый пароль должен отличаться');cachedOldBundle=await derive(oldP.el.value);await verifyOldPassword(cachedOldBundle);cachedNewBundle=await derive(newP.el.value);const s=await client.start({newRootKey:cachedNewBundle.rootPair.publicKeyB64,newBlockchainKey:cachedNewBundle.blockchainPair.publicKeyB64,newClientKey:cachedNewBundle.clientPair.publicKeyB64,forkFromBlock:selected.blockNumber,forkFromHash:selected.blockHash,reasonCode:Number(reason.value),comment:comment.value});await runCopy(s); }catch(e){error.textContent=e?.message||String(e);start.disabled=false;}});
setRoot(head,oldP.wrap,newP.wrap,new2.wrap,reasonWrap,comment,list,more,start,error);void load();
}
async function askNewPasswordAndCopy(s){
const card=statusCard(s), p=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить копирование';const err=document.createElement('div');err.className='meta-muted';go.addEventListener('click',async()=>{go.disabled=true;try{cachedNewBundle=await derive(p.el.value);if(cachedNewBundle.blockchainPair.publicKeyB64!==s.newBlockchainKey)throw new Error('Этот пароль выводит другой новый blockchain key');await runCopy(s);}catch(e){err.textContent=e?.message||e;go.disabled=false;}});const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(card,note('Для продолжения на этом устройстве введите тот же новый пароль, который использовался при запуске.'),p.wrap,go,abort,err);
}
async function runCopy(s){
const card=statusCard(s), info=note('Создаётся новая копия выбранной части цепочки в Arweave/Turbo. Обычные записи аккаунта в это время заблокированы.');setRoot(card,info);
try{await client.copyCandidateChain({rotation:s,newBundle:cachedNewBundle,onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Прогресс: ${current} / ${total}`;}});await client.waitUntilPublished({onProgress:({current,total})=>{card.querySelectorAll('.meta-muted')[1].textContent=`Опубликовано: ${current} / ${total}`;}});await client.finishChain();await refresh();}catch(e){root.append(note(e?.message||String(e)));const retry=document.createElement('button');retry.className='primary-btn';retry.textContent='Повторить / продолжить';retry.addEventListener('click',()=>void refresh());root.append(retry);}
}
async function renderChainReady(s){
const oldP=input('Текущий пароль');const newP=input('Новый пароль');const go=document.createElement('button');go.className='primary-btn';go.textContent='Изменить ключи в Solana';const abort=document.createElement('button');abort.className='secondary-btn';abort.textContent='Прервать смену ключей';const err=document.createElement('div');err.className='meta-muted';
go.addEventListener('click',async()=>{go.disabled=true;try{cachedOldBundle=await derive(oldP.el.value);cachedNewBundle=await derive(newP.el.value);await client.rotatePda({login,solanaEndpoint,oldBundle:cachedOldBundle,newBundle:cachedNewBundle,storagePwd});await refresh();}catch(e){err.textContent=e?.message||String(e);go.disabled=false;}});abort.addEventListener('click',async()=>{await client.abort();await refresh();});setRoot(statusCard(s),note('Новая цепочка полностью готова. Следующий шаг — точка невозврата: старый root разрешит атомарную смену root + client + blockchain fork, а новое PDA подпишет новый blockchain key.'),oldP.wrap,newP.wrap,go,abort,err);
}
function pollView(s){setRoot(statusCard(s),note(s.rotationStatus==='REBUILDING_SERVER'?'Сервер перестраивает текущую рабочую историю по новому fork.':'Ожидаем подтверждение нового состояния PDA в Solana.'));timer=setTimeout(()=>void refresh(),POLL_MS);}
function placeholderView(s,kind){const wallet=kind==='wallet';const text=wallet?'Перевод SOL со старого blockchain-wallet на новый пока не реализован. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.':'Перешифрование старых личных сообщений новым client key пока не реализовано. Этот этап сейчас будет отмечен как NOT_IMPLEMENTED и пропущен.';const go=document.createElement('button');go.className='primary-btn';go.textContent='Продолжить';go.addEventListener('click',async()=>{go.disabled=true;try{const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();}catch(e){go.disabled=false;root.append(note(e?.message||String(e)));}});setRoot(statusCard(s),note(text),go);}
async function refresh(){if(disposed)return;if(timer){clearTimeout(timer);timer=null;}try{const s=await client.status();const st=String(s.rotationStatus||'NONE');if(st==='NONE'){await renderNone();return;}if(st==='COPYING_CHAIN'){if(cachedNewBundle)await runCopy(s);else await askNewPasswordAndCopy(s);return;}if(st==='CHAIN_READY'){await renderChainReady(s);return;}if(['ROTATING_PDA','PDA_ROTATED','REBUILDING_SERVER'].includes(st)){pollView(s);return;}if(st==='WALLET_MIGRATION'){placeholderView(s,'wallet');return;}if(st==='MESSAGE_MIGRATION'){placeholderView(s,'messages');return;}if(st==='FINALIZING'){const next=await client.continuePlaceholder();if(String(next?.rotationStatus||'')==='COMPLETE'){navigate('my-blockchain-view');return;}await refresh();return;}if(st==='COMPLETE'){setRoot(note('Смена ключей завершена.'));return;}setRoot(note(`Неизвестное состояние ротации: ${st}`));}catch(e){setRoot(note(`Не удалось прочитать состояние смены ключей: ${e?.message||e}`));}}
void refresh();screen.cleanup=()=>{disposed=true;if(timer)clearTimeout(timer);};return screen;
}
+49
View File
@@ -0,0 +1,49 @@
import { createTopBar } from '../components/topbar.js';
import { authService } from '../state.js';
import { KeyRotationClient } from '../services/key-rotation-service.js';
export const pageMeta = { id: 'my-blockchain-view', title: 'Мой блокчейн' };
const TYPE_LABELS = new Map([
['0:0','Начало блокчейна'], ['0:2','Смена ключей / fork'],
['1:1','Публикация'], ['1:2','Ответ'], ['1:3','Редактирование'],
['2:1','Реакция'], ['3:1','Связь'], ['4:1','Статус'],
]);
function shortHash(v){const s=String(v||'');return s.length>18?`${s.slice(0,9)}…${s.slice(-7)}`:s;}
function eventLabel(item){return TYPE_LABELS.get(`${item.msgType}:${item.msgSubType}`)||`Событие ${item.msgType}/${item.msgSubType}`;}
function formatDate(ms){try{return new Date(Number(ms)||0).toLocaleString('ru-RU');}catch{return '';}}
export function render({ navigate, chrome }) {
const screen=document.createElement('section'); screen.className='stack';
chrome?.setTopbar(createTopBar({ title:'Мой блокчейн', back:{label:'←',onClick:()=>navigate('settings-view')} }));
const intro=document.createElement('div'); intro.className='card stack';
intro.innerHTML=`<strong>История ваших действий</strong><span class="meta-muted">Это текущая активная версия вашего блокчейна. Хэши записей сохраняют логическую идентичность при fork.</span>`;
const rotate=document.createElement('button'); rotate.type='button'; rotate.className='primary-btn'; rotate.textContent='Сменить пароль / ключи'; rotate.addEventListener('click',()=>navigate('key-rotation-view'));
intro.append(rotate); screen.append(intro);
const list=document.createElement('div'); list.className='stack'; screen.append(list);
const more=document.createElement('button'); more.type='button'; more.className='secondary-btn'; more.textContent='Показать более ранние записи'; more.hidden=true; screen.append(more);
const client=new KeyRotationClient(authService); let before=null; let loading=false; let disposed=false;
async function load(reset=false){
if(loading||disposed)return; loading=true; more.disabled=true;
if(reset){list.innerHTML='';before=null;}
try{
const data=await client.getMyBlockchain({beforeBlock:before,limit:50,includeBlockBytes:false});
if(reset){const head=document.createElement('div');head.className='card stack';head.innerHTML=`<strong>${data.blockchainName||'—'}</strong><span class="meta-muted">Последний блок: #${Number(data.tipBlockNumber??-1)} · ${shortHash(data.tipBlockHash)}</span>`;list.append(head);}
for(const item of data.blocks||[]){
const card=document.createElement('div');card.className='card stack';
const target=item.toLogin?`<span class="meta-muted">Цель: ${escapeHtml(item.toLogin)} #${item.toBlockNumber??'—'} · ${shortHash(item.toBlockHash)}</span>`:'';
card.innerHTML=`<div><strong>#${item.blockNumber} · ${escapeHtml(eventLabel(item))}</strong></div><span class="meta-muted">${formatDate(item.timestampMs)} · hash ${shortHash(item.blockHash)}</span>${target}`;
list.append(card);
}
before=data.nextBeforeBlock;
more.hidden=before==null;
if(!(data.blocks||[]).length && reset){const empty=document.createElement('div');empty.className='card';empty.textContent='Записей пока нет.';list.append(empty);}
}catch(e){const err=document.createElement('div');err.className='card';err.textContent=`Не удалось загрузить блокчейн: ${e?.message||e}`;list.append(err);}
finally{loading=false;more.disabled=false;}
}
more.addEventListener('click',()=>void load(false)); void load(true);
screen.cleanup=()=>{disposed=true;}; return screen;
}
function escapeHtml(value){return String(value??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
@@ -126,7 +126,7 @@ export function render({ navigate }) {
// Blockchain key
const bchSep = document.createElement('p');
bchSep.className = 'field-label';
bchSep.textContent = 'Blockchain key';
bchSep.textContent = 'Blockchain key (= Solana wallet)';
card.append(bchSep);
card.append(makePublicField({
label: 'Blockchain — публичный (base58)',
@@ -140,7 +140,7 @@ export function render({ navigate }) {
// Client key
const devSep = document.createElement('p');
devSep.className = 'field-label';
devSep.textContent = 'Client key (= Solana wallet)';
devSep.textContent = 'Client key';
card.append(devSep);
card.append(makePublicField({
label: 'Client — публичный (base58)',
@@ -234,7 +234,7 @@ export function render({ navigate }) {
const deriveUserWalletAddress = async () => {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle) throw new Error('Ключи ещё не сгенерированы. Вернитесь на предыдущий шаг.');
const { publicKeyB64 } = keyBundle.clientPair;
const { publicKeyB64 } = keyBundle.blockchainPair;
const raw = atob(publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
@@ -387,7 +387,7 @@ export function render({ navigate }) {
await refreshBalance({ addressOverride: walletAddress });
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось подготовить client.key: ${error?.message || 'unknown'}`;
status.textContent = `Не удалось подготовить blockchain key: ${error?.message || 'unknown'}`;
status.style.display = '';
}
})();
+2
View File
@@ -73,6 +73,7 @@ export function render({navigate, chrome}) {
</div>
<div class="nav-list">
${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
${row('settings-my-blockchain', 'Мой блокчейн', 'Все ваши подписанные события и смена ключей')}
${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')}
@@ -104,6 +105,7 @@ export function render({navigate, chrome}) {
});
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-my-blockchain').addEventListener('click', () => navigate('my-blockchain-view'));
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
+7 -8
View File
@@ -14,15 +14,14 @@ const DEVNET_ENDPOINT = 'https://api.devnet.solana.com';
const SENDER_PRIVATE_32_BASE58 = '6xqAuKYvA8qrCdAkcw7Y8aMgvBnYk8JLxWLma5BzbAvu';
const REGISTRATION_TOPUP_AMOUNT_SOL = 0.02;
// Канонический Solana-адрес пополнения = публичный device-ключ из сгенерированного набора ключей.
// Тот же путь, что в registration-payment-view (deriveUserWalletAddress); не выводим адрес
// напрямую из пароля, иначе он расходится с device-ключом регистрации.
async function clientWalletAddressFromBundle() {
// Канонический Solana-кошелёк пользователя = текущий blockchain key.
// Тот же адрес используется как fee payer для регистрации и обычных PDA update.
async function blockchainWalletAddressFromBundle() {
const keyBundle = state.registrationDraft.preGeneratedKeyBundle;
if (!keyBundle || !keyBundle.clientPair) {
if (!keyBundle || !keyBundle.blockchainPair) {
throw new Error('Ключи ещё не сгенерированы. Вернитесь на экран регистрации.');
}
const raw = atob(keyBundle.clientPair.publicKeyB64);
const raw = atob(keyBundle.blockchainPair.publicKeyB64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
const { PublicKey } = await loadSolanaWeb3();
@@ -73,7 +72,7 @@ export function render({ navigate }) {
card.innerHTML = `
<p class="auth-copy">Можете или пополнить счёт тестовыми соланами и продолжить регистрацию.</p>
<div class="card stack" style="padding:12px; max-width:320px;">
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (client key = Solana wallet)</div>
<div class="field-label" style="margin-bottom:6px;">Кошелёк для пополнения (blockchain key = Solana wallet)</div>
</div>
<div class="stack" style="gap:6px;">
<p class="meta-muted">Или можете отдельно открыть страницу тестового пополнения.</p>
@@ -139,7 +138,7 @@ export function render({ navigate }) {
(async () => {
try {
if (!walletValue.value) {
const address = await clientWalletAddressFromBundle();
const address = await blockchainWalletAddressFromBundle();
state.registrationPayment.walletAddress = address;
walletValue.value = address;
}
+10 -10
View File
@@ -5,7 +5,7 @@ import {
formatSol,
getBalanceSol,
getTopupSiteUrl,
getWalletFromStoredClientKey,
getWalletFromStoredBlockchainKey,
transferSol,
} from '../services/solana-wallet-service.js';
import {
@@ -983,7 +983,7 @@ export function render({navigate, chrome}) {
<h2 style="margin:0;">Купить билет на сумму в долларах</h2>
<p class="meta-muted" style="margin:0; line-height:1.55;">
Здесь вводится сумма покупки и адрес получателя. Если адрес не указан, можно купить на тот же кошелёк, с которого идет оплата.
Покупка подписывается вашим client key и отклоняется, если курс уходит дальше допустимого порога.
Оплата выполняется с текущего blockchain key. Технические подписи протокола покупки остаются отдельной частью операции.
</p>
`;
@@ -1169,7 +1169,7 @@ export function render({navigate, chrome}) {
currentCore = await loadSupportPaymentsCore(state.entrySettings.solanaServer);
}
if (!walletCtx) {
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
walletAddress = walletCtx.address;
}
setRecipientFromWallet();
@@ -1265,7 +1265,7 @@ export function render({navigate, chrome}) {
if (!walletCtx) {
try {
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
walletAddress = walletCtx.address;
if (modeToken !== activeModeToken) return;
setRecipientFromWallet();
@@ -1867,7 +1867,7 @@ export function render({navigate, chrome}) {
addressCard.className = 'card';
addressCard.style.padding = '10px';
addressCard.innerHTML = `
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (client.key)</p>
<p class="meta-muted" style="margin-bottom:6px;">Публичный адрес (blockchain.key)</p>
<p style="font-size:13px; line-height:1.4; word-break:break-all;" id="wallet-address-value">—</p>
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
@@ -1932,7 +1932,7 @@ export function render({navigate, chrome}) {
sendBtn.addEventListener('click', async () => {
if (!walletCtx?.keypair) {
setStatus('Перевод недоступен: client.key не загружен.');
setStatus('Перевод недоступен: blockchain.key не загружен.');
return;
}
const toAddress = window.prompt('Введите адрес получателя (Solana):', '');
@@ -1968,17 +1968,17 @@ export function render({navigate, chrome}) {
});
content.append(backBtn, card, actions);
setStatus('Инициализация client.key...');
setStatus('Инициализация blockchain.key...');
try {
walletCtx = await getWalletFromStoredClientKey(sessionArgsOrThrow());
walletCtx = await getWalletFromStoredBlockchainKey(sessionArgsOrThrow());
if (modeToken !== activeModeToken) return;
walletAddress = walletCtx.address;
addressEl.textContent = walletAddress;
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'client.key недоступен';
addressEl.textContent = 'blockchain.key недоступен';
setStatus(`Не удалось инициализировать кошелёк: ${error?.message || 'unknown'}`);
}
}
@@ -2146,7 +2146,7 @@ export function render({navigate, chrome}) {
await refreshBalance();
} catch (error) {
if (modeToken !== activeModeToken) return;
addressEl.textContent = 'client.key недоступен';
addressEl.textContent = 'blockchain.key недоступен';
clearArweaveSecretsInMemory();
setStatus(`Не удалось инициализировать Arweave-кошелёк: ${error?.message || 'unknown'}`);
}