diff --git a/Dev_Docs/Pending_Features/2026-06-16_1410_wallet_pda_keys_и_homeserver_выбор.md b/Dev_Docs/Pending_Features/2026-06-16_1410_wallet_pda_keys_и_homeserver_выбор.md new file mode 100644 index 0000000..cc8d29a --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-16_1410_wallet_pda_keys_и_homeserver_выбор.md @@ -0,0 +1,17 @@ +# Wallet plugin: PDA-ключи и выбор homeserver + +- краткое описание: + `SHiNE-browser-plugin-wallet` после session-only подключения сохраняет `login`, публичные `root/device/blockchain` ключи из PDA и список опубликованных `homeserver`-сессий. Постоянное подключение не удерживается: plugin остаётся офлайн, а список trusted devices обновляет по запросу. + +- что проверять: + 1. После успешного pairing plugin показывает сохранённую wallet-session без автоматического постоянного подключения. + 2. В карточке wallet-session виден сокращённый `deviceKey`. + 3. Кнопка `Обновить устройства` подтягивает homeserver-сессии из PDA и показывает их список со статусом `online/offline/unknown`. + 4. В селекте ключа подписи доступен `deviceKey (ed25519, ...)`. + 5. Кнопка `Запросить подпись` не падает и честно сообщает, что signaling подписи ещё не доделан. + +- ожидаемый результат: + кошелёк хранит PDA-метаданные локально, не висит всё время онлайн и показывает каркас выбора ключа и устройства перед будущим этапом signaling подписи. + +- статус: + pending diff --git a/SHiNE-browser-plugin-wallet/background.js b/SHiNE-browser-plugin-wallet/background.js index 49eeeee..e5ddc71 100644 --- a/SHiNE-browser-plugin-wallet/background.js +++ b/SHiNE-browser-plugin-wallet/background.js @@ -5,6 +5,7 @@ import { DEFAULT_SHINE_SERVER_LOGIN, buildHttpBase, normalizeServerLogin, + readWalletProfileByLogin, resolveShineServerByServerLogin, } from './js/lib/shine-server-resolver.js'; @@ -24,6 +25,12 @@ const state = { pollTimer: 0, activeSession: null, connectionOnline: false, + walletProfile: null, + signing: { + selectedKeyId: 'device', + selectedDeviceName: '', + devicesResolvedAtMs: 0, + }, statusText: '', statusKind: 'info', }; @@ -80,6 +87,12 @@ async function loadStateFromStorage() { login: String(settings?.login || '').trim(), }; state.activeSession = await loadSessionMaterial(); + state.walletProfile = state.activeSession?.walletProfile || null; + state.signing = { + selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'), + selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''), + devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0), + }; } async function persistSettings(nextSettings = {}) { @@ -93,7 +106,89 @@ async function persistSettings(nextSettings = {}) { return state.settings; } -async function resumeActiveSession() { +async function saveActiveSessionRecord() { + if (!state.activeSession) return; + const nextRecord = { + ...state.activeSession, + walletProfile: state.walletProfile, + selectedKeyId: state.signing.selectedKeyId, + selectedDeviceName: state.signing.selectedDeviceName, + devicesResolvedAtMs: state.signing.devicesResolvedAtMs, + }; + state.activeSession = nextRecord; + await saveSessionMaterial(nextRecord); +} + +function shortKey(value = '', size = 10) { + const raw = String(value || '').trim(); + return raw ? raw.slice(0, size) : ''; +} + +function buildSigningKeyOptions(walletProfile) { + const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim(); + if (!deviceKey) return []; + return [{ + id: 'device', + label: `deviceKey (ed25519, ${shortKey(deviceKey)})`, + keyType: 'ed25519', + publicKeyBase58: deviceKey, + }]; +} + +function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) { + const published = Array.isArray(publishedHomeservers) ? publishedHomeservers : []; + const homeserverSessions = Array.isArray(serverSessions) + ? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100) + : []; + const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer); + + return published.map((item) => { + let onlineState = 'unknown'; + if (published.length === 1) { + onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline'; + } else if (onlineHomeservers.length === 0) { + onlineState = 'offline'; + } else if (onlineHomeservers.length === published.length) { + onlineState = 'online'; + } + return { + ...item, + onlineState, + onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown', + }; + }); +} + +async function hydrateWalletProfile(login) { + const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim(); + if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.'); + const profile = await readWalletProfileByLogin(cleanLogin); + const signingKeyOptions = buildSigningKeyOptions(profile); + const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId) + ? state.signing.selectedKeyId + : (signingKeyOptions[0]?.id || ''); + const selectedDeviceName = state.signing.selectedDeviceName + || String(profile?.homeserverSessions?.[0]?.sessionName || ''); + + state.walletProfile = { + ...profile, + signingKeyOptions, + homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({ + ...item, + onlineState: 'unknown', + onlineLabel: 'unknown', + })) : [], + }; + state.signing = { + ...state.signing, + selectedKeyId, + selectedDeviceName, + }; + await saveActiveSessionRecord(); + return state.walletProfile; +} + +async function resumeActiveSession({ keepConnected = false } = {}) { const sessionRecord = await loadSessionMaterial(); state.activeSession = sessionRecord; if (!sessionRecord) { @@ -110,8 +205,14 @@ async function resumeActiveSession() { login: String(sessionRecord?.login || state.settings.login || '').trim(), }); const resumed = await ensureApi().resumeSession(sessionRecord); - state.connectionOnline = true; - setStatus(`Wallet-session активна для @${resumed.login}.`, 'info'); + state.connectionOnline = !!keepConnected; + if (!keepConnected) { + ensureApi().close(); + state.api = null; + setStatus(`Wallet-session сохранена для @${resumed.login}. Подключение будет открываться только по действию.`, 'info'); + } else { + setStatus(`Wallet-session активна для @${resumed.login}.`, 'info'); + } return { ok: true, connected: true, login: resumed.login, sessionId: resumed.sessionId }; } catch (error) { state.connectionOnline = false; @@ -142,13 +243,14 @@ async function attachApprovedSession(payload) { } await clearSessionMaterial(); - await saveSessionMaterial(sessionRecord); state.activeSession = sessionRecord; + await hydrateWalletProfile(login); + await saveActiveSessionRecord(); await persistSettings({ login: sessionRecord.login, serverUrl: sessionRecord.serverUrl, }); - await resumeActiveSession(); + state.connectionOnline = false; } async function pollPairingStatus() { @@ -166,7 +268,7 @@ async function pollPairingStatus() { const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, state.requesterMaterial); await attachApprovedSession(decoded); clearPairingState(); - setStatus('Wallet-session создана и сохранена.', 'info'); + setStatus('Wallet-session создана и сохранена. Кошелёк остаётся офлайн до запроса подписи.', 'info'); return; } if (stateValue === 'rejected') { @@ -251,13 +353,87 @@ async function cancelPairing() { } async function disconnectSession() { + ensureApi().close(); + state.api = null; await clearSessionMaterial(); state.activeSession = null; state.connectionOnline = false; + state.walletProfile = null; + state.signing = { + selectedKeyId: 'device', + selectedDeviceName: '', + devicesResolvedAtMs: 0, + }; setStatus('Сохранённая wallet-session удалена из plugin.', 'info'); return { ok: true }; } +async function refreshWalletDevices() { + if (!state.activeSession?.login) { + throw new Error('Сначала подключите wallet-session.'); + } + await hydrateWalletProfile(state.activeSession.login); + const resumed = await resumeActiveSession({ keepConnected: true }); + if (!resumed.ok) { + throw new Error(resumed.error || 'Не удалось открыть wallet-session.'); + } + try { + const sessions = await ensureApi().listSessions(); + state.walletProfile = { + ...state.walletProfile, + homeserverSessions: mergeHomeserverStatuses(state.walletProfile?.homeserverSessions, sessions), + }; + state.signing.devicesResolvedAtMs = Date.now(); + if (!state.signing.selectedDeviceName && state.walletProfile.homeserverSessions[0]?.sessionName) { + state.signing.selectedDeviceName = state.walletProfile.homeserverSessions[0].sessionName; + } + await saveActiveSessionRecord(); + setStatus('Список доверенных homeserver-устройств обновлён.', 'info'); + return { + ok: true, + devices: state.walletProfile.homeserverSessions, + }; + } finally { + ensureApi().close(); + state.api = null; + state.connectionOnline = false; + } +} + +async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) { + state.signing = { + ...state.signing, + selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''), + selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''), + }; + await saveActiveSessionRecord(); + return { ok: true }; +} + +async function prepareSignSignal() { + if (!state.activeSession?.login) { + throw new Error('Сначала подключите wallet-session.'); + } + if (!state.signing.selectedKeyId) { + throw new Error('Не выбран ключ подписи.'); + } + if (!state.signing.selectedDeviceName) { + throw new Error('Не выбрано устройство homeserver.'); + } + const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName); + if (!selectedDevice) { + throw new Error('Выбранное устройство не найдено в PDA аккаунта.'); + } + setStatus( + `Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`, + 'info', + ); + return { + ok: true, + pending: true, + }; +} + function snapshot() { return { settings: { ...state.settings }, @@ -270,6 +446,8 @@ function snapshot() { }, session: state.activeSession ? { ...state.activeSession } : null, connectionOnline: state.connectionOnline, + walletProfile: state.walletProfile ? { ...state.walletProfile } : null, + signing: { ...state.signing }, status: { text: state.statusText, kind: state.statusKind, @@ -305,6 +483,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { sendResponse({ ok: true, result, state: snapshot() }); return; } + if (type === 'wallet:refreshWalletDevices') { + const result = await refreshWalletDevices(); + sendResponse({ ok: true, result, state: snapshot() }); + return; + } + if (type === 'wallet:updateSigningSelection') { + const result = await updateSigningSelection(message?.payload || {}); + sendResponse({ ok: true, result, state: snapshot() }); + return; + } + if (type === 'wallet:prepareSignSignal') { + const result = await prepareSignSignal(); + sendResponse({ ok: true, result, state: snapshot() }); + return; + } if (type === 'wallet:disconnectSession') { const result = await disconnectSession(); sendResponse({ ok: true, result, state: snapshot() }); @@ -318,6 +511,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return true; }); -void loadStateFromStorage().then(() => resumeActiveSession()).catch((error) => { +void loadStateFromStorage().then(async () => { + if (state.activeSession?.login) { + await hydrateWalletProfile(state.activeSession.login).catch(() => {}); + setStatus(`Wallet-session сохранена для @${state.activeSession.login}. Подключение будет открываться только по действию.`, 'info'); + } +}).catch((error) => { setStatus(error?.message || 'Не удалось инициализировать wallet plugin.', 'error'); }); diff --git a/SHiNE-browser-plugin-wallet/js/lib/shine-api.js b/SHiNE-browser-plugin-wallet/js/lib/shine-api.js index 9a5c9a8..5b51bc5 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/shine-api.js +++ b/SHiNE-browser-plugin-wallet/js/lib/shine-api.js @@ -69,6 +69,12 @@ export class ShineApiClient { return response.payload || {}; } + async listSessions() { + const response = await this.ws.request('ListSessions', {}); + if (response.status !== 200) throw opError('ListSessions', response); + return Array.isArray(response?.payload?.sessions) ? response.payload.sessions : []; + } + async resumeSession(sessionRecord) { const login = String(sessionRecord?.login || '').trim(); const sessionId = String(sessionRecord?.sessionId || '').trim(); diff --git a/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js b/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js index 1f1bed8..2c50327 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js +++ b/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js @@ -69,21 +69,32 @@ function parseServerFieldsFromUserPda(dataBytes) { let isServer = false; let serverAddress = ''; let accessServers = []; + let rootKey32 = null; + let deviceKey32 = null; + let blockchainKey32 = null; + let blockchainName = ''; + let homeserverSessions = []; for (let i = 0; i < blocksCount; i += 1) { const blockType = readU8(bytes, cursorRef); cursorRef.value += 1; // block_version if (blockType === 1 || blockType === 2) { - cursorRef.value += 32; + const key32 = readBytes(bytes, cursorRef, 32); + if (blockType === 1) rootKey32 = key32; + if (blockType === 2) deviceKey32 = key32; continue; } if (blockType === 3) { const count = readU8(bytes, cursorRef); for (let j = 0; j < count; j += 1) { cursorRef.value += 1; - readStrU8(bytes, cursorRef); - cursorRef.value += 32; + const currentBlockchainName = readStrU8(bytes, cursorRef); + const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32); + if (!blockchainKey32) { + blockchainKey32 = currentBlockchainKey32; + blockchainName = currentBlockchainName; + } cursorRef.value += 8 + 8 + 4 + 32 + 64; const arPresent = readU8(bytes, cursorRef); if (arPresent === 1) readStrU8(bytes, cursorRef); @@ -111,9 +122,19 @@ function parseServerFieldsFromUserPda(dataBytes) { cursorRef.value += 1; const sessionsCount = readU8(bytes, cursorRef); for (let j = 0; j < sessionsCount; j += 1) { - cursorRef.value += 1 + 1; - readStrU8(bytes, cursorRef); - cursorRef.value += 32; + const sessionType = readU8(bytes, cursorRef); + const sessionVersion = readU8(bytes, cursorRef); + const sessionName = readStrU8(bytes, cursorRef); + const sessionPubKey32 = readBytes(bytes, cursorRef, 32); + if (sessionType === 100) { + homeserverSessions.push({ + sessionType, + sessionVersion, + sessionName, + sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(), + sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`, + }); + } } continue; } @@ -128,6 +149,13 @@ function parseServerFieldsFromUserPda(dataBytes) { isServer, serverAddress: normalizeHostLike(serverAddress), accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean), + publicKeys: { + rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '', + deviceKeyBase58: deviceKey32 ? new PublicKey(deviceKey32).toBase58() : '', + blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '', + blockchainName, + }, + homeserverSessions, }; } @@ -176,6 +204,17 @@ export async function resolveShineServerByServerLogin(serverLogin, solanaEndpoin }; } +export async function readWalletProfileByLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) { + const cleanLogin = normalizeServerLogin(login); + const parsed = await fetchUserPda(cleanLogin, solanaEndpoint); + return { + login: cleanLogin, + accessServers: parsed.accessServers, + publicKeys: parsed.publicKeys, + homeserverSessions: parsed.homeserverSessions, + }; +} + export { DEFAULT_SHINE_SERVER_ADDRESS, DEFAULT_SHINE_SERVER_LOGIN, diff --git a/SHiNE-browser-plugin-wallet/popup.css b/SHiNE-browser-plugin-wallet/popup.css index 37ebfc3..7123418 100644 --- a/SHiNE-browser-plugin-wallet/popup.css +++ b/SHiNE-browser-plugin-wallet/popup.css @@ -88,7 +88,8 @@ body { } input[type="text"], -input[type="password"] { +input[type="password"], +select { width: 100%; padding: 10px 12px; border: 1px solid #314459; @@ -178,3 +179,33 @@ input[type="password"] { .hidden { display: none; } + +.device-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.device-row { + padding: 8px 10px; + border: 1px solid #243446; + border-radius: 8px; + background: #0d141d; +} + +.device-state { + font-size: 12px; + text-transform: lowercase; +} + +.device-state-online { + color: #b7f5ce; +} + +.device-state-offline { + color: #ffc4cf; +} + +.device-state-unknown { + color: #f8e2a0; +} diff --git a/SHiNE-browser-plugin-wallet/popup.html b/SHiNE-browser-plugin-wallet/popup.html index 87f781d..948143c 100644 --- a/SHiNE-browser-plugin-wallet/popup.html +++ b/SHiNE-browser-plugin-wallet/popup.html @@ -28,12 +28,36 @@
Логин
Session ID
Типwallet
+
deviceKey
- + +
+ +
Войти через другое устройство