SHA256
ESP32 wallet RPC, browser wallet provider, and side panel
This commit is contained in:
@@ -26,14 +26,44 @@ const state = {
|
||||
connectionOnline: false,
|
||||
walletProfile: null,
|
||||
signing: {
|
||||
selectedKeyId: 'device',
|
||||
selectedDeviceName: '',
|
||||
devicesResolvedAtMs: 0,
|
||||
},
|
||||
currentWallet: null,
|
||||
statusText: '',
|
||||
statusKind: 'info',
|
||||
};
|
||||
|
||||
const WALLET_RPC_REQUEST_TYPE = 9100;
|
||||
const WALLET_RPC_RESPONSE_TYPE = 9101;
|
||||
|
||||
async function configureSidePanelBehavior() {
|
||||
if (!chrome.sidePanel?.setPanelBehavior) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||
} catch (error) {
|
||||
console.warn('Failed to configure SHiNE side panel behavior:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrigin(value = '') {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).origin;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function makeCodeError(message, code) {
|
||||
const error = new Error(String(message || 'Wallet error'));
|
||||
error.code = String(code || '').trim().toUpperCase();
|
||||
return error;
|
||||
}
|
||||
|
||||
function setStatus(message = '', kind = 'info') {
|
||||
state.statusText = String(message || '');
|
||||
state.statusKind = kind === 'error' ? 'error' : 'info';
|
||||
@@ -71,14 +101,15 @@ async function loadStateFromStorage() {
|
||||
serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'),
|
||||
serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
|
||||
login: String(settings?.login || '').trim(),
|
||||
connectedOrigins: Array.isArray(settings?.connectedOrigins) ? settings.connectedOrigins.map((item) => normalizeOrigin(item)).filter(Boolean) : [],
|
||||
};
|
||||
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),
|
||||
};
|
||||
state.currentWallet = state.activeSession?.currentWallet || null;
|
||||
}
|
||||
|
||||
async function persistSettings(nextSettings = {}) {
|
||||
@@ -86,10 +117,29 @@ async function persistSettings(nextSettings = {}) {
|
||||
...state.settings,
|
||||
...nextSettings,
|
||||
};
|
||||
if (!Array.isArray(state.settings.connectedOrigins)) {
|
||||
state.settings.connectedOrigins = [];
|
||||
}
|
||||
await savePluginSettings(state.settings);
|
||||
return state.settings;
|
||||
}
|
||||
|
||||
function isOriginApproved(origin) {
|
||||
const normalized = normalizeOrigin(origin);
|
||||
return !!normalized && Array.isArray(state.settings.connectedOrigins) && state.settings.connectedOrigins.includes(normalized);
|
||||
}
|
||||
|
||||
async function setOriginApproved(origin, approved) {
|
||||
const normalized = normalizeOrigin(origin);
|
||||
const current = new Set(Array.isArray(state.settings.connectedOrigins) ? state.settings.connectedOrigins : []);
|
||||
if (approved) {
|
||||
if (normalized) current.add(normalized);
|
||||
} else if (normalized) {
|
||||
current.delete(normalized);
|
||||
}
|
||||
await persistSettings({ connectedOrigins: [...current] });
|
||||
}
|
||||
|
||||
async function resolveServerForLogin(login) {
|
||||
const cleanLogin = String(login || state.settings.login || '').trim();
|
||||
if (!cleanLogin) {
|
||||
@@ -119,9 +169,9 @@ async function saveActiveSessionRecord() {
|
||||
const nextRecord = {
|
||||
...state.activeSession,
|
||||
walletProfile: state.walletProfile,
|
||||
selectedKeyId: state.signing.selectedKeyId,
|
||||
selectedDeviceName: state.signing.selectedDeviceName,
|
||||
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
|
||||
currentWallet: state.currentWallet,
|
||||
};
|
||||
state.activeSession = nextRecord;
|
||||
await saveSessionMaterial(nextRecord);
|
||||
@@ -152,27 +202,10 @@ function toWalletErrorMessage(error, fallback = 'Не удалось выпол
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function buildSigningKeyOptions(walletProfile) {
|
||||
const rootKey = String(walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
|
||||
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
|
||||
const options = [];
|
||||
if (rootKey) {
|
||||
options.push({
|
||||
id: 'root',
|
||||
label: `rootKey (ed25519, ${shortKey(rootKey)})`,
|
||||
keyType: 'ed25519',
|
||||
publicKeyBase58: rootKey,
|
||||
});
|
||||
}
|
||||
if (deviceKey) {
|
||||
options.push({
|
||||
id: 'device',
|
||||
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
|
||||
keyType: 'ed25519',
|
||||
publicKeyBase58: deviceKey,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
function homeserverSessionNameFromClientInfo(value = '') {
|
||||
const raw = String(value || '').trim();
|
||||
const match = raw.match(/^ESP32 homeserver:(.+)$/i);
|
||||
return match ? String(match[1] || '').trim() : '';
|
||||
}
|
||||
|
||||
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
|
||||
@@ -181,18 +214,25 @@ function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = [])
|
||||
? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100)
|
||||
: [];
|
||||
const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer);
|
||||
const byName = new Map();
|
||||
onlineHomeservers.forEach((item) => {
|
||||
const sessionName = homeserverSessionNameFromClientInfo(item?.clientInfoFromClient);
|
||||
if (sessionName) {
|
||||
byName.set(sessionName, item);
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
const matched = byName.get(String(item?.sessionName || '').trim()) || null;
|
||||
let onlineState = matched ? 'online' : 'offline';
|
||||
let activeSessionId = matched?.sessionId ? String(matched.sessionId) : '';
|
||||
if (!matched && published.length === 1 && onlineHomeservers.length === 1) {
|
||||
onlineState = 'online';
|
||||
activeSessionId = String(onlineHomeservers[0]?.sessionId || '');
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
activeSessionId,
|
||||
onlineState,
|
||||
onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown',
|
||||
};
|
||||
@@ -203,25 +243,20 @@ 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',
|
||||
activeSessionId: '',
|
||||
})) : [],
|
||||
};
|
||||
state.signing = {
|
||||
...state.signing,
|
||||
selectedKeyId,
|
||||
selectedDeviceName,
|
||||
};
|
||||
await saveActiveSessionRecord();
|
||||
@@ -293,6 +328,7 @@ async function attachApprovedSession(payload) {
|
||||
serverUrl: sessionRecord.serverUrl,
|
||||
});
|
||||
state.connectionOnline = false;
|
||||
state.currentWallet = null;
|
||||
}
|
||||
|
||||
async function pollPairingStatus() {
|
||||
@@ -400,10 +436,10 @@ async function disconnectSession() {
|
||||
state.connectionOnline = false;
|
||||
state.walletProfile = null;
|
||||
state.signing = {
|
||||
selectedKeyId: 'device',
|
||||
selectedDeviceName: '',
|
||||
devicesResolvedAtMs: 0,
|
||||
};
|
||||
state.currentWallet = null;
|
||||
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -440,37 +476,199 @@ async function refreshWalletDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) {
|
||||
async function updateSigningSelection({ 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() {
|
||||
async function resolveSelectedHomeserverSession() {
|
||||
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);
|
||||
let selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
|
||||
if (!selectedDevice) {
|
||||
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
|
||||
}
|
||||
setStatus(
|
||||
`Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`,
|
||||
'info',
|
||||
);
|
||||
if (!selectedDevice.activeSessionId) {
|
||||
await refreshWalletDevices();
|
||||
selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
|
||||
}
|
||||
if (!selectedDevice?.activeSessionId) {
|
||||
throw new Error('Выбранный homeserver сейчас не найден онлайн на сервере SHiNE.');
|
||||
}
|
||||
return selectedDevice;
|
||||
}
|
||||
|
||||
async function callWalletRpc(requestData, timeoutMs = 8000) {
|
||||
const selectedDevice = await resolveSelectedHomeserverSession();
|
||||
const resumed = await resumeActiveSession({ keepConnected: true });
|
||||
if (!resumed.ok) {
|
||||
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
|
||||
}
|
||||
|
||||
const requestId = String(requestData?.requestId || `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`);
|
||||
const callId = `wallet-rpc-${requestId}`;
|
||||
const payload = {
|
||||
...requestData,
|
||||
requestId,
|
||||
timeMs: Number(requestData?.timeMs || Date.now()),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
off();
|
||||
reject(new Error('Таймаут ответа от ESP32.'));
|
||||
}, timeoutMs);
|
||||
const off = ensureApi().onEvent('IncomingCallSignal', (evt) => {
|
||||
const eventPayload = evt?.payload || {};
|
||||
if (String(eventPayload?.callId || '') !== callId) return;
|
||||
if (Number(eventPayload?.type || 0) !== WALLET_RPC_RESPONSE_TYPE) return;
|
||||
clearTimeout(timeoutId);
|
||||
off();
|
||||
try {
|
||||
resolve(JSON.parse(String(eventPayload?.data || '{}')));
|
||||
} catch {
|
||||
reject(new Error('ESP32 вернул некорректный JSON.'));
|
||||
}
|
||||
});
|
||||
ensureApi().callSignalToSession({
|
||||
toLogin: state.activeSession.login,
|
||||
targetSessionId: selectedDevice.activeSessionId,
|
||||
callId,
|
||||
type: WALLET_RPC_REQUEST_TYPE,
|
||||
data: JSON.stringify(payload),
|
||||
}).catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
off();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
return { response, selectedDevice, requestId };
|
||||
} finally {
|
||||
ensureApi().close();
|
||||
state.api = null;
|
||||
state.connectionOnline = false;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyWalletAgainstPda(wallet) {
|
||||
const type = String(wallet?.type || '').trim();
|
||||
const pub = String(wallet?.publicKeyBase58 || '').trim();
|
||||
const rootKey = String(state.walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
|
||||
const deviceKey = String(state.walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
|
||||
if (type === 'dev.key') {
|
||||
return {
|
||||
verified: !!deviceKey && deviceKey === pub,
|
||||
verificationText: deviceKey === pub ? 'Совпадает с deviceKey из PDA.' : 'Не совпадает с deviceKey из PDA.',
|
||||
};
|
||||
}
|
||||
if (type === 'root.key') {
|
||||
return {
|
||||
verified: !!rootKey && rootKey === pub,
|
||||
verificationText: rootKey === pub ? 'Совпадает с rootKey из PDA.' : 'Не совпадает с rootKey из PDA.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
verified: null,
|
||||
verificationText: 'Для custom-кошелька проверка через PDA пока не выполняется.',
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCurrentWallet() {
|
||||
const { response, selectedDevice, requestId } = await callWalletRpc({
|
||||
v: 1,
|
||||
operation: 'get_wallet_public_key',
|
||||
requestId: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(`ESP32 отклонил запрос: ${String(response?.error || 'unknown_error')}`);
|
||||
}
|
||||
state.currentWallet = {
|
||||
type: String(response?.wallet?.type || '').trim(),
|
||||
publicKeyBase58: String(response?.wallet?.publicKeyBase58 || '').trim(),
|
||||
homeserverName: selectedDevice.sessionName,
|
||||
requestId: String(response?.requestId || requestId),
|
||||
timeMs: Number(response?.timeMs || 0),
|
||||
...verifyWalletAgainstPda(response?.wallet || {}),
|
||||
};
|
||||
await saveActiveSessionRecord();
|
||||
setStatus(`Кошелёк получен с ${selectedDevice.sessionName}.`, 'info');
|
||||
return { ok: true, wallet: state.currentWallet };
|
||||
}
|
||||
|
||||
async function siteConnect({ origin, onlyIfTrusted = false } = {}) {
|
||||
const normalizedOrigin = normalizeOrigin(origin);
|
||||
if (!normalizedOrigin) {
|
||||
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
|
||||
}
|
||||
if (onlyIfTrusted && !isOriginApproved(normalizedOrigin)) {
|
||||
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
|
||||
}
|
||||
const result = await requestCurrentWallet();
|
||||
const publicKeyBase58 = String(result?.wallet?.publicKeyBase58 || '').trim();
|
||||
if (!publicKeyBase58) {
|
||||
throw makeCodeError('Wallet public key is not available.', 'WALLET_UNAVAILABLE');
|
||||
}
|
||||
if (!isOriginApproved(normalizedOrigin)) {
|
||||
await setOriginApproved(normalizedOrigin, true);
|
||||
}
|
||||
setStatus(`Site ${normalizedOrigin} connected to ${shortKey(publicKeyBase58, 8)}.`, 'info');
|
||||
return {
|
||||
ok: true,
|
||||
pending: true,
|
||||
publicKeyBase58,
|
||||
walletType: String(result?.wallet?.type || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function siteDisconnect({ origin } = {}) {
|
||||
const normalizedOrigin = normalizeOrigin(origin);
|
||||
setStatus(normalizedOrigin ? `Site ${normalizedOrigin} disconnected.` : 'Site disconnected.', 'info');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function siteSignTransaction({ origin, publicKeyBase58, transactionBase64, comment } = {}) {
|
||||
const normalizedOrigin = normalizeOrigin(origin);
|
||||
if (!normalizedOrigin) {
|
||||
throw makeCodeError('Site origin is missing.', 'BAD_ORIGIN');
|
||||
}
|
||||
if (!isOriginApproved(normalizedOrigin)) {
|
||||
throw makeCodeError('Site is not trusted yet.', 'NOT_TRUSTED');
|
||||
}
|
||||
const cleanPub = String(publicKeyBase58 || '').trim();
|
||||
const cleanTx = String(transactionBase64 || '').trim();
|
||||
if (!cleanPub || !cleanTx) {
|
||||
throw makeCodeError('Transaction payload is incomplete.', 'BAD_REQUEST');
|
||||
}
|
||||
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const signComment = String(comment || '').trim() || `Site ${normalizedOrigin} requested transaction signature`;
|
||||
const { response } = await callWalletRpc({
|
||||
v: 1,
|
||||
operation: 'sign_transaction',
|
||||
requestId,
|
||||
publicKeyBase58: cleanPub,
|
||||
transactionBase64: cleanTx,
|
||||
comment: signComment,
|
||||
}, 120000);
|
||||
if (!response?.ok) {
|
||||
const errorCode = String(response?.error || 'unknown_error').trim().toUpperCase();
|
||||
if (errorCode === 'REJECTED_BY_USER') {
|
||||
throw makeCodeError('User rejected transaction signature on ESP32.', 'USER_REJECTED');
|
||||
}
|
||||
throw makeCodeError(`ESP32 rejected transaction signature: ${String(response?.error || 'unknown_error')}`, errorCode || 'RPC_REJECTED');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
publicKeyBase58: String(response?.publicKeyBase58 || cleanPub).trim(),
|
||||
signedTransactionBase64: String(response?.signedTransactionBase64 || '').trim(),
|
||||
signatureBase58: String(response?.signatureBase58 || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -485,8 +683,9 @@ function snapshot() {
|
||||
trustedSessionOnline: state.trustedSessionOnline,
|
||||
},
|
||||
session: state.activeSession ? { ...state.activeSession } : null,
|
||||
connectionOnline: state.connectionOnline,
|
||||
connectionOnline: !!state.activeSession,
|
||||
walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
|
||||
currentWallet: state.currentWallet ? { ...state.currentWallet } : null,
|
||||
signing: { ...state.signing },
|
||||
status: {
|
||||
text: state.statusText,
|
||||
@@ -538,8 +737,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
sendResponse({ ok: true, result, state: snapshot() });
|
||||
return;
|
||||
}
|
||||
if (type === 'wallet:prepareSignSignal') {
|
||||
const result = await prepareSignSignal();
|
||||
if (type === 'wallet:requestCurrentWallet') {
|
||||
const result = await requestCurrentWallet();
|
||||
sendResponse({ ok: true, result, state: snapshot() });
|
||||
return;
|
||||
}
|
||||
@@ -548,15 +747,40 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
sendResponse({ ok: true, result, state: snapshot() });
|
||||
return;
|
||||
}
|
||||
if (type === 'wallet:siteConnect') {
|
||||
const result = await siteConnect(message?.payload || {});
|
||||
sendResponse({ ok: true, result, state: snapshot() });
|
||||
return;
|
||||
}
|
||||
if (type === 'wallet:siteDisconnect') {
|
||||
const result = await siteDisconnect(message?.payload || {});
|
||||
sendResponse({ ok: true, result, state: snapshot() });
|
||||
return;
|
||||
}
|
||||
if (type === 'wallet:siteSignTransaction') {
|
||||
const result = await siteSignTransaction(message?.payload || {});
|
||||
sendResponse({ ok: true, result, state: snapshot() });
|
||||
return;
|
||||
}
|
||||
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
|
||||
})().catch((error) => {
|
||||
const message = toWalletErrorMessage(error, 'Unknown error');
|
||||
setStatus(message, 'error');
|
||||
sendResponse({ ok: false, error: message, state: snapshot() });
|
||||
sendResponse({ ok: false, error: message, code: String(error?.code || ''), state: snapshot() });
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
void configureSidePanelBehavior();
|
||||
});
|
||||
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
void configureSidePanelBehavior();
|
||||
});
|
||||
|
||||
void configureSidePanelBehavior();
|
||||
|
||||
void loadStateFromStorage().then(async () => {
|
||||
if (state.activeSession?.login) {
|
||||
await hydrateWalletProfile(state.activeSession.login).catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user