From 1b7602be391f9762b4d721c40101a5b127a52a9c Mon Sep 17 00:00:00 2001 From: thomasurbit <99678438+thomasurbit@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:03:18 +0200 Subject: [PATCH 01/14] feat: add popup / side panel display mode toggle Let users choose between the toolbar pop-up and Chrome side panel via Settings > Display mode. Side panel reuses the same React app with fluid layout; dApp approvals route to the panel with popup-window fallback. Co-authored-by: Cursor --- extension/background/index.ts | 131 +++++++++++- extension/manifest.json | 5 +- extension/popup/Router.tsx | 3 + extension/popup/assets/display-mode-icon.svg | 4 + .../popup/components/ScreenContainer.tsx | 2 +- extension/popup/hooks/useApprovalDetection.ts | 199 +++++++++++------- extension/popup/screens/AboutScreen.tsx | 4 +- .../popup/screens/AddWalletStartScreen.tsx | 4 +- extension/popup/screens/DisplayModeScreen.tsx | 179 ++++++++++++++++ extension/popup/screens/HomeScreen.tsx | 2 +- .../screens/KeySettingsPasswordScreen.tsx | 2 +- extension/popup/screens/LockTimeScreen.tsx | 2 +- extension/popup/screens/ReceiveScreen.tsx | 4 +- .../popup/screens/RecoveryPhraseScreen.tsx | 4 +- extension/popup/screens/RpcSettingsScreen.tsx | 2 +- extension/popup/screens/SendReviewScreen.tsx | 4 +- extension/popup/screens/SendScreen.tsx | 2 +- .../popup/screens/SendSubmittedScreen.tsx | 4 +- extension/popup/screens/SettingsScreen.tsx | 12 +- extension/popup/screens/SwapReviewScreen.tsx | 2 +- extension/popup/screens/SwapScreen.tsx | 2 +- .../popup/screens/ThemeSettingsScreen.tsx | 2 +- .../screens/TransactionDetailsScreen.tsx | 6 +- .../popup/screens/V0MigrationFundsScreen.tsx | 2 +- .../popup/screens/V0MigrationIntroScreen.tsx | 2 +- .../popup/screens/V0MigrationReviewScreen.tsx | 2 +- .../popup/screens/V0MigrationSetupScreen.tsx | 4 +- .../popup/screens/ViewSecretPhraseScreen.tsx | 2 +- .../popup/screens/WalletPermissionsScreen.tsx | 2 +- .../popup/screens/WalletSettingsScreen.tsx | 4 +- .../popup/screens/WalletStylingScreen.tsx | 4 +- .../approvals/ConnectApprovalScreen.tsx | 7 +- .../screens/approvals/SignMessageScreen.tsx | 7 +- .../screens/approvals/SignRawTxScreen.tsx | 7 +- .../approvals/TransactionApprovalScreen.tsx | 7 +- .../popup/screens/onboarding/BackupScreen.tsx | 4 +- .../popup/screens/onboarding/CreateScreen.tsx | 4 +- .../popup/screens/onboarding/ImportScreen.tsx | 8 +- .../onboarding/ImportSuccessScreen.tsx | 4 +- .../screens/onboarding/ResumeBackupScreen.tsx | 4 +- .../popup/screens/onboarding/StartScreen.tsx | 4 +- .../screens/onboarding/SuccessScreen.tsx | 4 +- .../popup/screens/onboarding/VerifyScreen.tsx | 6 +- .../popup/screens/system/LockedScreen.tsx | 2 +- extension/popup/store.ts | 1 + extension/popup/styles.css | 20 ++ extension/popup/utils/displayContext.ts | 20 ++ extension/shared/constants.ts | 28 +++ extension/sidepanel/index.html | 13 ++ extension/sidepanel/index.tsx | 19 ++ 50 files changed, 615 insertions(+), 157 deletions(-) create mode 100644 extension/popup/assets/display-mode-icon.svg create mode 100644 extension/popup/screens/DisplayModeScreen.tsx create mode 100644 extension/popup/utils/displayContext.ts create mode 100644 extension/sidepanel/index.html create mode 100644 extension/sidepanel/index.tsx diff --git a/extension/background/index.ts b/extension/background/index.ts index 7ecf01e2..844aea88 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -31,7 +31,10 @@ import { UI_CONSTANTS, APPROVAL_CONSTANTS, CHAIN_ID, + DISPLAY_MODES, + RUNTIME_MESSAGE_TYPES, } from '../shared/constants'; +import type { DisplayMode, ApprovalType } from '../shared/constants'; import { getEffectiveRpcConfig } from '../shared/rpc-config'; import type { RpcConfig } from '../shared/rpc-config'; import type { @@ -49,6 +52,7 @@ let manuallyLocked = false; // Track if user manually locked (don't auto-unlock) let approvalWindowId: number | null = null; // Track the approval popup window for reuse let isCreatingWindow = false; // Prevent race condition when creating window let currentRequestId: string | null = null; // Currently displayed request +let currentRequestType: ApprovalType | null = null; // Type of currently displayed request let requestQueue: Array<{ id: string; type: 'connect' | 'transaction' | 'sign-message' | 'sign-raw-tx'; @@ -240,6 +244,7 @@ function cancelPendingRequest(requestId: string, code?: number, message?: string }); if (currentRequestId == requestId) { currentRequestId = null; + currentRequestType = null; } } @@ -421,6 +426,7 @@ interface PendingRequest { sendResponse: (response: any) => void; origin: string; needsUnlock?: boolean; // Flag indicating request is waiting for wallet unlock + tabId?: number; } const pendingRequests = new Map(); @@ -461,6 +467,65 @@ function isTransactionRequest( return 'to' in request; } +async function getDisplayMode(): Promise { + if (!chrome.sidePanel) { + return DISPLAY_MODES.POPUP; + } + + const stored = await chrome.storage.local.get([STORAGE_KEYS.DISPLAY_MODE]); + const mode = stored[STORAGE_KEYS.DISPLAY_MODE]; + return mode === DISPLAY_MODES.SIDE_PANEL ? DISPLAY_MODES.SIDE_PANEL : DISPLAY_MODES.POPUP; +} + +async function applyDisplayMode(mode: DisplayMode): Promise { + const effectiveMode = + mode === DISPLAY_MODES.SIDE_PANEL && chrome.sidePanel ? DISPLAY_MODES.SIDE_PANEL : DISPLAY_MODES.POPUP; + + if (effectiveMode === DISPLAY_MODES.SIDE_PANEL) { + await chrome.action.setPopup({ popup: '' }); + await chrome.sidePanel!.setPanelBehavior({ openPanelOnActionClick: true }); + await chrome.sidePanel!.setOptions({ path: 'sidepanel/index.html', enabled: true }); + } else { + await chrome.action.setPopup({ popup: 'popup/index.html' }); + if (chrome.sidePanel) { + await chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }); + } + } +} + +async function notifyApprovalPending(requestId: string, type: ApprovalType): Promise { + try { + await chrome.runtime.sendMessage({ + type: RUNTIME_MESSAGE_TYPES.APPROVAL_PENDING, + requestId, + approvalType: type, + }); + } catch { + // Side panel may not be open yet; it will query on mount via GET_PENDING_APPROVAL + } +} + +async function openSidePanelForApproval(tabId?: number): Promise { + if (!chrome.sidePanel) { + return false; + } + + try { + if (tabId !== undefined) { + await chrome.sidePanel.open({ tabId }); + } else { + const currentWindow = await chrome.windows.getLastFocused({ populate: false }); + if (currentWindow.id !== undefined) { + await chrome.sidePanel.open({ windowId: currentWindow.id }); + } + } + return true; + } catch (error) { + console.warn('[Background] sidePanel.open failed, falling back to popup window:', error); + return false; + } +} + async function createPopupWithFallback(opts: any): Promise { // First try: with left/top try { @@ -480,7 +545,8 @@ async function createPopupWithFallback(opts: any): Promise { */ async function createApprovalPopup( requestId: string, - type: 'connect' | 'transaction' | 'sign-message' | 'sign-raw-tx' + type: ApprovalType, + tabId?: number ) { // If user is currently viewing a different request, queue this one if (currentRequestId !== null && currentRequestId !== requestId) { @@ -494,6 +560,16 @@ async function createApprovalPopup( // Mark this request as currently displayed currentRequestId = requestId; + currentRequestType = type; + + const displayMode = await getDisplayMode(); + if (displayMode === DISPLAY_MODES.SIDE_PANEL) { + const opened = await openSidePanelForApproval(tabId); + if (opened) { + await notifyApprovalPending(requestId, type); + return; + } + } let hashPrefix: string; if (type === 'connect') { @@ -530,7 +606,7 @@ async function createApprovalPopup( // Prevent race condition: if window is being created, wait and retry if (isCreatingWindow) { await new Promise(resolve => setTimeout(resolve, 100)); - return createApprovalPopup(requestId, type); + return createApprovalPopup(requestId, type, tabId); } // Create new approval window @@ -588,6 +664,7 @@ function processNextRequest() { if (currentRequestId !== null) { cancelPendingRequest(currentRequestId); } + currentRequestType = null; if (requestQueue.length > 0) { while (true) { @@ -595,7 +672,7 @@ function processNextRequest() { if (!pendingRequests.has(next.id)) { continue; } - createApprovalPopup(next.id, next.type); + createApprovalPopup(next.id, next.type, pendingRequests.get(next.id)?.tabId); break; } } @@ -687,6 +764,8 @@ const initPromise = (async () => { await vault.init(); // Load encrypted vault header to detect vault existence await restoreUnlockSession(); // Rehydrate unlock state if still within auto-lock window + await applyDisplayMode(await getDisplayMode()); + // Only schedule alarm if auto-lock is enabled, otherwise ensure any stale alarm is cleared if (autoLockMinutes > 0) { scheduleAlarm(); @@ -695,6 +774,10 @@ const initPromise = (async () => { } })(); +chrome.runtime.onInstalled.addListener(async () => { + await applyDisplayMode(await getDisplayMode()); +}); + // Clean up approval window ID when window is closed chrome.windows.onRemoved.addListener(windowId => { if (windowId === approvalWindowId) { @@ -788,10 +871,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { void sendBridgedResponse(response); }, origin: connectRequest.origin, + tabId: _sender.tab?.id, }); // Create approval popup - await createApprovalPopup(connectRequestId, 'connect'); + await createApprovalPopup(connectRequestId, 'connect', _sender.tab?.id); // Response will be sent when user approves/rejects return; @@ -843,10 +927,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { void sendBridgedResponse(response); }, origin: signRequest.origin, + tabId: _sender.tab?.id, }); // Create approval popup - await createApprovalPopup(newSignRequestId, 'sign-message'); + await createApprovalPopup(newSignRequestId, 'sign-message', _sender.tab?.id); // Response will be sent when user approves/rejects return; @@ -896,10 +981,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { void sendBridgedResponse(response); }, origin: signRawTxRequest.origin, + tabId: _sender.tab?.id, }); // Create approval popup - await createApprovalPopup(signRawTxId, 'sign-raw-tx'); + await createApprovalPopup(signRawTxId, 'sign-raw-tx', _sender.tab?.id); // Response will be sent when user approves/rejects return; @@ -951,10 +1037,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { void sendBridgedResponse(response); }, origin: txRequest.origin, + tabId: _sender.tab?.id, }); // Create approval popup - await createApprovalPopup(txRequestId, 'transaction'); + await createApprovalPopup(txRequestId, 'transaction', _sender.tab?.id); // Response will be sent when user approves/rejects return; @@ -1002,6 +1089,36 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse({ ok: true }); return; + case INTERNAL_METHODS.GET_DISPLAY_MODE: + sendResponse({ mode: await getDisplayMode() }); + return; + + case INTERNAL_METHODS.SET_DISPLAY_MODE: { + const requestedMode = payload.params?.[0]; + if (requestedMode !== DISPLAY_MODES.POPUP && requestedMode !== DISPLAY_MODES.SIDE_PANEL) { + sendResponse({ error: ERROR_CODES.INVALID_PARAMS }); + return; + } + + const resolvedMode = + requestedMode === DISPLAY_MODES.SIDE_PANEL && chrome.sidePanel + ? DISPLAY_MODES.SIDE_PANEL + : DISPLAY_MODES.POPUP; + + await chrome.storage.local.set({ [STORAGE_KEYS.DISPLAY_MODE]: resolvedMode }); + await applyDisplayMode(resolvedMode); + sendResponse({ success: true, mode: resolvedMode }); + return; + } + + case INTERNAL_METHODS.GET_PENDING_APPROVAL: + if (currentRequestId && currentRequestType && pendingRequests.has(currentRequestId)) { + sendResponse({ requestId: currentRequestId, approvalType: currentRequestType }); + } else { + sendResponse(null); + } + return; + case INTERNAL_METHODS.UNLOCK: const unlockResult = await vault.unlock(payload.params?.[0]); // password sendResponse(unlockResult); diff --git a/extension/manifest.json b/extension/manifest.json index 6868d215..4439c48c 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -24,7 +24,10 @@ "service_worker": "background/index.ts", "type": "module" }, - "permissions": ["storage", "alarms"], + "permissions": ["storage", "alarms", "sidePanel"], + "side_panel": { + "default_path": "sidepanel/index.html" + }, "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; font-src 'self'" }, diff --git a/extension/popup/Router.tsx b/extension/popup/Router.tsx index 05e4ded0..b4533d56 100644 --- a/extension/popup/Router.tsx +++ b/extension/popup/Router.tsx @@ -30,6 +30,7 @@ import { TransactionApprovalScreen } from './screens/approvals/TransactionApprov import { SignRawTxScreen } from './screens/approvals/SignRawTxScreen'; import { SettingsScreen } from './screens/SettingsScreen'; import { ThemeSettingsScreen } from './screens/ThemeSettingsScreen'; +import { DisplayModeScreen } from './screens/DisplayModeScreen'; import { LockTimeScreen } from './screens/LockTimeScreen'; import { KeySettingsPasswordScreen } from './screens/KeySettingsPasswordScreen'; import { RpcSettingsScreen } from './screens/RpcSettingsScreen'; @@ -84,6 +85,8 @@ export function Router() { return ; case 'theme-settings': return ; + case 'display-mode': + return ; case 'lock-time': return ; case 'key-settings': diff --git a/extension/popup/assets/display-mode-icon.svg b/extension/popup/assets/display-mode-icon.svg new file mode 100644 index 00000000..38e26515 --- /dev/null +++ b/extension/popup/assets/display-mode-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/components/ScreenContainer.tsx b/extension/popup/components/ScreenContainer.tsx index c949aece..535870e6 100644 --- a/extension/popup/components/ScreenContainer.tsx +++ b/extension/popup/components/ScreenContainer.tsx @@ -10,7 +10,7 @@ interface ScreenContainerProps { } export function ScreenContainer({ children, className = '' }: ScreenContainerProps) { - const baseClasses = 'w-[357px] h-[600px] p-4'; + const baseClasses = 'w-full h-full p-4'; const combinedClasses = className ? `${baseClasses} ${className}` : baseClasses; return
{children}
; diff --git a/extension/popup/hooks/useApprovalDetection.ts b/extension/popup/hooks/useApprovalDetection.ts index 9a54bd82..c55ab587 100644 --- a/extension/popup/hooks/useApprovalDetection.ts +++ b/extension/popup/hooks/useApprovalDetection.ts @@ -1,20 +1,19 @@ /** - * useApprovalDetection - Detect and handle approval requests from URL hash + * useApprovalDetection - Detect and handle approval requests from URL hash or side panel messages * - * This hook monitors the URL hash for approval request parameters and automatically - * fetches the corresponding request data from the background script, then navigates - * to the appropriate approval screen when the wallet is unlocked. - * - * @param walletAddress - The current wallet address (null if not initialized) - * @param walletLocked - Whether the wallet is currently locked - * @param setPendingTransactionRequest - Function to set pending transaction request - * @param setPendingSignRequest - Function to set pending sign request - * @param navigate - Navigation function to switch screens + * This hook monitors the URL hash for approval request parameters (popup windows) and + * runtime messages / background queries (side panel), then navigates to the appropriate + * approval screen when the wallet is unlocked. */ -import { useEffect } from 'react'; +import { useEffect, useCallback } from 'react'; import { send } from '../utils/messaging'; -import { INTERNAL_METHODS, APPROVAL_CONSTANTS } from '../../shared/constants'; +import { + INTERNAL_METHODS, + APPROVAL_CONSTANTS, + RUNTIME_MESSAGE_TYPES, +} from '../../shared/constants'; +import type { ApprovalType } from '../../shared/constants'; import type { TransactionRequest, SignRequest, @@ -22,6 +21,7 @@ import type { SignRawTxRequest, } from '../../shared/types'; import type { Screen } from '../store'; +import { isSidePanel } from '../utils/displayContext'; interface UseApprovalDetectionProps { walletAddress: string | null; @@ -33,6 +33,19 @@ interface UseApprovalDetectionProps { navigate: (screen: Screen) => void; } +function getApprovalScreen(type: ApprovalType): Screen { + switch (type) { + case 'connect': + return 'connect-approval'; + case 'transaction': + return 'approve-transaction'; + case 'sign-message': + return 'sign-message'; + case 'sign-raw-tx': + return 'approve-sign-raw-tx'; + } +} + export function useApprovalDetection({ walletAddress, walletLocked, @@ -42,6 +55,63 @@ export function useApprovalDetection({ setPendingSignRawTxRequest, navigate, }: UseApprovalDetectionProps) { + const handleApproval = useCallback( + async (requestId: string, type: ApprovalType) => { + const targetScreen = getApprovalScreen(type); + const lockedScreen: Screen = 'locked'; + + if (type === 'connect') { + const request = await send(INTERNAL_METHODS.GET_PENDING_CONNECTION, [ + requestId, + ]); + if (request && !('error' in request)) { + setPendingConnectRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + return; + } + + if (type === 'transaction') { + const request = await send(INTERNAL_METHODS.GET_PENDING_TRANSACTION, [ + requestId, + ]); + if (request && !('error' in request)) { + setPendingTransactionRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + return; + } + + if (type === 'sign-message') { + const request = await send(INTERNAL_METHODS.GET_PENDING_SIGN_REQUEST, [ + requestId, + ]); + if (request && !('error' in request)) { + setPendingSignRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + return; + } + + const request = await send( + INTERNAL_METHODS.GET_PENDING_SIGN_RAW_TX_REQUEST, + [requestId] + ); + if (request && !('error' in request)) { + setPendingSignRawTxRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + }, + [ + walletLocked, + setPendingConnectRequest, + setPendingTransactionRequest, + setPendingSignRequest, + setPendingSignRawTxRequest, + navigate, + ] + ); + useEffect(() => { // Wait for wallet state to be initialized if (walletAddress === null) return; @@ -50,79 +120,48 @@ export function useApprovalDetection({ if (hash.startsWith(APPROVAL_CONSTANTS.CONNECT_HASH_PREFIX)) { const requestId = hash.replace(APPROVAL_CONSTANTS.CONNECT_HASH_PREFIX, ''); - - // Fetch pending connect request from background - send(INTERNAL_METHODS.GET_PENDING_CONNECTION, [requestId]) - .then(request => { - if (request && !('error' in request)) { - setPendingConnectRequest(request); - // Navigate to approval screen if unlocked, or locked screen if locked - if (!walletLocked) { - navigate('connect-approval'); - } else { - navigate('locked'); - } - } - }) - .catch(console.error); + void handleApproval(requestId, 'connect').catch(console.error); } else if (hash.startsWith(APPROVAL_CONSTANTS.TRANSACTION_HASH_PREFIX)) { const requestId = hash.replace(APPROVAL_CONSTANTS.TRANSACTION_HASH_PREFIX, ''); - - // Fetch pending transaction request from background - send(INTERNAL_METHODS.GET_PENDING_TRANSACTION, [requestId]) - .then(request => { - if (request && !('error' in request)) { - setPendingTransactionRequest(request); - // Navigate to approval screen if unlocked, or locked screen if locked - if (!walletLocked) { - navigate('approve-transaction'); - } else { - navigate('locked'); - } - } - }) - .catch(console.error); + void handleApproval(requestId, 'transaction').catch(console.error); } else if (hash.startsWith(APPROVAL_CONSTANTS.SIGN_MESSAGE_HASH_PREFIX)) { const requestId = hash.replace(APPROVAL_CONSTANTS.SIGN_MESSAGE_HASH_PREFIX, ''); - - // Fetch pending sign request from background - send(INTERNAL_METHODS.GET_PENDING_SIGN_REQUEST, [requestId]) - .then(request => { - if (request && !('error' in request)) { - setPendingSignRequest(request); - // Navigate to approval screen if unlocked, or locked screen if locked - if (!walletLocked) { - navigate('sign-message'); - } else { - navigate('locked'); - } - } - }) - .catch(console.error); + void handleApproval(requestId, 'sign-message').catch(console.error); } else if (hash.startsWith(APPROVAL_CONSTANTS.SIGN_RAW_TX_HASH_PREFIX)) { const requestId = hash.replace(APPROVAL_CONSTANTS.SIGN_RAW_TX_HASH_PREFIX, ''); - - // Fetch pending sign raw tx request from background - send(INTERNAL_METHODS.GET_PENDING_SIGN_RAW_TX_REQUEST, [requestId]) - .then(request => { - if (request && !('error' in request)) { - setPendingSignRawTxRequest(request); - // Navigate to approval screen if unlocked, or locked screen if locked - if (!walletLocked) { - navigate('approve-sign-raw-tx'); - } else { - navigate('locked'); - } - } - }) - .catch(console.error); + void handleApproval(requestId, 'sign-raw-tx').catch(console.error); } - }, [ - walletAddress, - walletLocked, - setPendingConnectRequest, - setPendingTransactionRequest, - setPendingSignRequest, - navigate, - ]); + }, [walletAddress, handleApproval]); + + useEffect(() => { + if (!isSidePanel() || walletAddress === null) return; + + const handleRuntimeMessage = (message: { + type?: string; + requestId?: string; + approvalType?: ApprovalType; + }) => { + if ( + message?.type === RUNTIME_MESSAGE_TYPES.APPROVAL_PENDING && + message.requestId && + message.approvalType + ) { + void handleApproval(message.requestId, message.approvalType).catch(console.error); + } + }; + + chrome.runtime.onMessage.addListener(handleRuntimeMessage); + + send<{ requestId: string; approvalType: ApprovalType } | null>( + INTERNAL_METHODS.GET_PENDING_APPROVAL + ) + .then(pending => { + if (pending?.requestId && pending.approvalType) { + void handleApproval(pending.requestId, pending.approvalType).catch(console.error); + } + }) + .catch(console.error); + + return () => chrome.runtime.onMessage.removeListener(handleRuntimeMessage); + }, [walletAddress, handleApproval]); } diff --git a/extension/popup/screens/AboutScreen.tsx b/extension/popup/screens/AboutScreen.tsx index b64146ad..72608297 100644 --- a/extension/popup/screens/AboutScreen.tsx +++ b/extension/popup/screens/AboutScreen.tsx @@ -31,7 +31,7 @@ export function AboutScreen() { return (
{/* Header */} @@ -57,7 +57,7 @@ export function AboutScreen() { {/* Content */} -
+
Iris
diff --git a/extension/popup/screens/AddWalletStartScreen.tsx b/extension/popup/screens/AddWalletStartScreen.tsx index cbb0f77c..af149fd8 100644 --- a/extension/popup/screens/AddWalletStartScreen.tsx +++ b/extension/popup/screens/AddWalletStartScreen.tsx @@ -10,7 +10,7 @@ export function AddWalletStartScreen() { const { navigate } = useStore(); return ( -
+
-
+
diff --git a/extension/popup/screens/DisplayModeScreen.tsx b/extension/popup/screens/DisplayModeScreen.tsx new file mode 100644 index 00000000..a4682035 --- /dev/null +++ b/extension/popup/screens/DisplayModeScreen.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react'; +import { useStore } from '../store'; +import { send } from '../utils/messaging'; +import { INTERNAL_METHODS, DISPLAY_MODES } from '../../shared/constants'; +import type { DisplayMode } from '../../shared/constants'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import { isSidePanel, isSidePanelSupported } from '../utils/displayContext'; + +export function DisplayModeScreen() { + const { navigate } = useStore(); + const [mode, setMode] = useState(DISPLAY_MODES.POPUP); + const [isSaving, setIsSaving] = useState(false); + const [showPopupHint, setShowPopupHint] = useState(false); + + useEffect(() => { + if (!isSidePanelSupported()) { + navigate('settings'); + } + }, [navigate]); + + useEffect(() => { + send<{ mode: DisplayMode }>(INTERNAL_METHODS.GET_DISPLAY_MODE) + .then(response => { + if (response?.mode) { + setMode(response.mode); + } + }) + .catch(console.error); + }, []); + + function handleBack() { + navigate('settings'); + } + + async function handleModeSelect(nextMode: DisplayMode) { + if (nextMode === mode || isSaving) return; + + setIsSaving(true); + setShowPopupHint(false); + + try { + const response = await send<{ success?: boolean; mode?: DisplayMode }>( + INTERNAL_METHODS.SET_DISPLAY_MODE, + [nextMode] + ); + + const resolvedMode = response?.mode ?? nextMode; + setMode(resolvedMode); + + if (resolvedMode === DISPLAY_MODES.SIDE_PANEL && !isSidePanel()) { + try { + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (activeTab?.id !== undefined) { + await chrome.sidePanel.open({ tabId: activeTab.id }); + } else { + const currentWindow = await chrome.windows.getCurrent(); + if (currentWindow.id !== undefined) { + await chrome.sidePanel.open({ windowId: currentWindow.id }); + } + } + window.close(); + } catch (error) { + console.error('[DisplayMode] Failed to open side panel:', error); + } + return; + } + + if (resolvedMode === DISPLAY_MODES.POPUP && isSidePanel()) { + setShowPopupHint(true); + } + } catch (error) { + console.error('[DisplayMode] Failed to update display mode:', error); + } finally { + setIsSaving(false); + } + } + + const Option = ({ + label, + description, + selected, + onClick, + disabled = false, + }: { + label: string; + description: string; + selected?: boolean; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ); + + if (!isSidePanelSupported()) { + return null; + } + + return ( +
+
+ +

Display mode

+
+
+ + {showPopupHint && ( +
+ Pop-up mode is active. Click the Iris icon in the toolbar to open the wallet in a pop-up window. +
+ )} + +
+
+
+ ); +} diff --git a/extension/popup/screens/HomeScreen.tsx b/extension/popup/screens/HomeScreen.tsx index c3a66c21..1203cb36 100644 --- a/extension/popup/screens/HomeScreen.tsx +++ b/extension/popup/screens/HomeScreen.tsx @@ -436,7 +436,7 @@ export function HomeScreen() { return (
diff --git a/extension/popup/screens/KeySettingsPasswordScreen.tsx b/extension/popup/screens/KeySettingsPasswordScreen.tsx index ffe264f3..9f26b9e2 100644 --- a/extension/popup/screens/KeySettingsPasswordScreen.tsx +++ b/extension/popup/screens/KeySettingsPasswordScreen.tsx @@ -55,7 +55,7 @@ export function KeySettingsPasswordScreen() { return (
{/* Header */} diff --git a/extension/popup/screens/LockTimeScreen.tsx b/extension/popup/screens/LockTimeScreen.tsx index 52d7bfbe..c8d937a9 100644 --- a/extension/popup/screens/LockTimeScreen.tsx +++ b/extension/popup/screens/LockTimeScreen.tsx @@ -85,7 +85,7 @@ export function LockTimeScreen() { return (
{/* Header */} diff --git a/extension/popup/screens/ReceiveScreen.tsx b/extension/popup/screens/ReceiveScreen.tsx index ca202018..b179981d 100644 --- a/extension/popup/screens/ReceiveScreen.tsx +++ b/extension/popup/screens/ReceiveScreen.tsx @@ -29,7 +29,7 @@ export function ReceiveScreen() { return (
{/* Header */} @@ -53,7 +53,7 @@ export function ReceiveScreen() { {/* Content */} -
+
{/* Intro */}
diff --git a/extension/popup/screens/RecoveryPhraseScreen.tsx b/extension/popup/screens/RecoveryPhraseScreen.tsx index 9b069172..7055c5ea 100644 --- a/extension/popup/screens/RecoveryPhraseScreen.tsx +++ b/extension/popup/screens/RecoveryPhraseScreen.tsx @@ -52,7 +52,7 @@ export function RecoveryPhraseScreen() { if (!isRevealed) { return (
{/* Header */} @@ -133,7 +133,7 @@ export function RecoveryPhraseScreen() { // Secret phrase display view return (
{/* Header */} diff --git a/extension/popup/screens/RpcSettingsScreen.tsx b/extension/popup/screens/RpcSettingsScreen.tsx index 9b6aefa2..9c06c70e 100644 --- a/extension/popup/screens/RpcSettingsScreen.tsx +++ b/extension/popup/screens/RpcSettingsScreen.tsx @@ -163,7 +163,7 @@ export function RpcSettingsScreen() { return (
{/* Header */} diff --git a/extension/popup/screens/SendReviewScreen.tsx b/extension/popup/screens/SendReviewScreen.tsx index 7f1617d0..bd041f87 100644 --- a/extension/popup/screens/SendReviewScreen.tsx +++ b/extension/popup/screens/SendReviewScreen.tsx @@ -100,7 +100,7 @@ export function SendReviewScreen() { return (
{/* Header */} @@ -127,7 +127,7 @@ export function SendReviewScreen() { {/* Content */}
diff --git a/extension/popup/screens/SendScreen.tsx b/extension/popup/screens/SendScreen.tsx index ae68b91d..11b5fade 100644 --- a/extension/popup/screens/SendScreen.tsx +++ b/extension/popup/screens/SendScreen.tsx @@ -421,7 +421,7 @@ export function SendScreen() { return (
{/* Header */} diff --git a/extension/popup/screens/SendSubmittedScreen.tsx b/extension/popup/screens/SendSubmittedScreen.tsx index 3c3b88c9..f33a7bbb 100644 --- a/extension/popup/screens/SendSubmittedScreen.tsx +++ b/extension/popup/screens/SendSubmittedScreen.tsx @@ -105,7 +105,7 @@ export function SendSubmittedScreen() { return (
{/* Header */} @@ -132,7 +132,7 @@ export function SendSubmittedScreen() { {/* Content */} -
+
{/* Success Section */}
diff --git a/extension/popup/screens/SettingsScreen.tsx b/extension/popup/screens/SettingsScreen.tsx index e15ab17e..8e7179b2 100644 --- a/extension/popup/screens/SettingsScreen.tsx +++ b/extension/popup/screens/SettingsScreen.tsx @@ -1,6 +1,7 @@ import { useStore } from '../store'; import IrisLogo from '../assets/iris-logo.svg'; import ThemeIcon from '../assets/theme-icon.svg'; +import DisplayModeIcon from '../assets/display-mode-icon.svg'; import RpcSettingsIcon from '../assets/rpc-settings-icon.svg'; import KeyIcon from '../assets/key-icon.svg'; import ClockIcon from '../assets/clock-icon.svg'; @@ -9,6 +10,7 @@ import { CloseIcon } from '../components/icons/CloseIcon'; import { ChevronRightIcon } from '../components/icons/ChevronRightIcon'; import AboutIcon from '../assets/settings-gear-icon.svg'; import { version } from '../../../package-lock.json'; +import { isSidePanelSupported } from '../utils/displayContext'; export function SettingsScreen() { const { navigate } = useStore(); @@ -19,6 +21,9 @@ export function SettingsScreen() { function handleThemeSettings() { navigate('theme-settings'); } + function handleDisplayMode() { + navigate('display-mode'); + } function handleKeySettings() { navigate('key-settings'); } @@ -65,7 +70,7 @@ export function SettingsScreen() { return (
{/* Header */} @@ -91,10 +96,13 @@ export function SettingsScreen() { {/* Content */} -
+
{/* Menu */}
+ {isSidePanelSupported() && ( + + )} diff --git a/extension/popup/screens/SwapReviewScreen.tsx b/extension/popup/screens/SwapReviewScreen.tsx index ef476cc2..75334897 100644 --- a/extension/popup/screens/SwapReviewScreen.tsx +++ b/extension/popup/screens/SwapReviewScreen.tsx @@ -125,7 +125,7 @@ export function SwapReviewScreen() { return (
{/* Header - matches Figma */} diff --git a/extension/popup/screens/ThemeSettingsScreen.tsx b/extension/popup/screens/ThemeSettingsScreen.tsx index 175886e9..bd505736 100644 --- a/extension/popup/screens/ThemeSettingsScreen.tsx +++ b/extension/popup/screens/ThemeSettingsScreen.tsx @@ -60,7 +60,7 @@ export function ThemeSettingsScreen() { return (
{/* Header */} diff --git a/extension/popup/screens/TransactionDetailsScreen.tsx b/extension/popup/screens/TransactionDetailsScreen.tsx index 8fc90b1c..f9499a03 100644 --- a/extension/popup/screens/TransactionDetailsScreen.tsx +++ b/extension/popup/screens/TransactionDetailsScreen.tsx @@ -52,7 +52,7 @@ export function TransactionDetailsScreen() { if (!selectedTransaction) { return (
@@ -227,7 +227,7 @@ export function TransactionDetailsScreen() { return (
{/* Header */} @@ -262,7 +262,7 @@ export function TransactionDetailsScreen() { {/* Content */}
diff --git a/extension/popup/screens/V0MigrationFundsScreen.tsx b/extension/popup/screens/V0MigrationFundsScreen.tsx index 44de3f22..1ddc9f6b 100644 --- a/extension/popup/screens/V0MigrationFundsScreen.tsx +++ b/extension/popup/screens/V0MigrationFundsScreen.tsx @@ -117,7 +117,7 @@ export function V0MigrationFundsScreen() { return (
diff --git a/extension/popup/screens/V0MigrationIntroScreen.tsx b/extension/popup/screens/V0MigrationIntroScreen.tsx index 6abf107d..f0e8ec60 100644 --- a/extension/popup/screens/V0MigrationIntroScreen.tsx +++ b/extension/popup/screens/V0MigrationIntroScreen.tsx @@ -16,7 +16,7 @@ export function V0MigrationIntroScreen() { return (
{/* Header - matches other migration screens */} diff --git a/extension/popup/screens/V0MigrationReviewScreen.tsx b/extension/popup/screens/V0MigrationReviewScreen.tsx index 659fec11..5057a318 100644 --- a/extension/popup/screens/V0MigrationReviewScreen.tsx +++ b/extension/popup/screens/V0MigrationReviewScreen.tsx @@ -152,7 +152,7 @@ export function V0MigrationReviewScreen() { return (
diff --git a/extension/popup/screens/V0MigrationSetupScreen.tsx b/extension/popup/screens/V0MigrationSetupScreen.tsx index 95e6b26d..c95ad974 100644 --- a/extension/popup/screens/V0MigrationSetupScreen.tsx +++ b/extension/popup/screens/V0MigrationSetupScreen.tsx @@ -150,7 +150,7 @@ export function V0MigrationSetupScreen() { } return ( -
+
{/* Header - same as onboarding ImportScreen */}
-
+
{/* Icon and instructions - same as ImportScreen */} diff --git a/extension/popup/screens/ViewSecretPhraseScreen.tsx b/extension/popup/screens/ViewSecretPhraseScreen.tsx index 611097cd..9a534f82 100644 --- a/extension/popup/screens/ViewSecretPhraseScreen.tsx +++ b/extension/popup/screens/ViewSecretPhraseScreen.tsx @@ -65,7 +65,7 @@ export function ViewSecretPhraseScreen() { return (
{/* Header */} diff --git a/extension/popup/screens/WalletPermissionsScreen.tsx b/extension/popup/screens/WalletPermissionsScreen.tsx index 31723a73..334ba6b4 100644 --- a/extension/popup/screens/WalletPermissionsScreen.tsx +++ b/extension/popup/screens/WalletPermissionsScreen.tsx @@ -45,7 +45,7 @@ export function WalletPermissionsScreen() { return (
{/* Header */} diff --git a/extension/popup/screens/WalletSettingsScreen.tsx b/extension/popup/screens/WalletSettingsScreen.tsx index 050cb626..c4ac7015 100644 --- a/extension/popup/screens/WalletSettingsScreen.tsx +++ b/extension/popup/screens/WalletSettingsScreen.tsx @@ -149,7 +149,7 @@ export function WalletSettingsScreen() { return (
{/* Header */} @@ -182,7 +182,7 @@ export function WalletSettingsScreen() { {/* Content */}
diff --git a/extension/popup/screens/WalletStylingScreen.tsx b/extension/popup/screens/WalletStylingScreen.tsx index 9d138171..c3d54710 100644 --- a/extension/popup/screens/WalletStylingScreen.tsx +++ b/extension/popup/screens/WalletStylingScreen.tsx @@ -149,7 +149,7 @@ export function WalletStylingScreen() { return (
{/* Header */} @@ -173,7 +173,7 @@ export function WalletStylingScreen() {
{/* Content */} -
+
{/* Preview */}
diff --git a/extension/popup/screens/approvals/ConnectApprovalScreen.tsx b/extension/popup/screens/approvals/ConnectApprovalScreen.tsx index a4e9a3ab..b1c37bb7 100644 --- a/extension/popup/screens/approvals/ConnectApprovalScreen.tsx +++ b/extension/popup/screens/approvals/ConnectApprovalScreen.tsx @@ -5,6 +5,7 @@ import { truncateAddress } from '../../utils/format'; import { send } from '../../utils/messaging'; import { INTERNAL_METHODS } from '../../../shared/constants'; import { useAutoRejectOnClose } from '../../hooks/useAutoRejectOnClose'; +import { closeAfterApproval } from '../../utils/displayContext'; export function ConnectApprovalScreen() { const { navigate, pendingConnectRequest, setPendingConnectRequest, wallet } = useStore(); @@ -22,13 +23,13 @@ export function ConnectApprovalScreen() { async function handleReject() { await send(INTERNAL_METHODS.REJECT_CONNECTION, [id]); setPendingConnectRequest(null); - window.close(); + closeAfterApproval(navigate); } async function handleConnect() { await send(INTERNAL_METHODS.APPROVE_CONNECTION, [id]); setPendingConnectRequest(null); - window.close(); + closeAfterApproval(navigate); } const bg = 'var(--color-bg)'; @@ -42,7 +43,7 @@ export function ConnectApprovalScreen() {
{/* Header */}
diff --git a/extension/popup/screens/approvals/SignMessageScreen.tsx b/extension/popup/screens/approvals/SignMessageScreen.tsx index ce69fa7a..5a3528a7 100644 --- a/extension/popup/screens/approvals/SignMessageScreen.tsx +++ b/extension/popup/screens/approvals/SignMessageScreen.tsx @@ -5,6 +5,7 @@ import { truncateAddress } from '../../utils/format'; import { send } from '../../utils/messaging'; import { INTERNAL_METHODS } from '../../../shared/constants'; import { useAutoRejectOnClose } from '../../hooks/useAutoRejectOnClose'; +import { closeAfterApproval } from '../../utils/displayContext'; export function SignMessageScreen() { const { navigate, pendingSignRequest, setPendingSignRequest, wallet } = useStore(); @@ -21,13 +22,13 @@ export function SignMessageScreen() { async function handleDecline() { await send(INTERNAL_METHODS.REJECT_SIGN_MESSAGE, [id]); setPendingSignRequest(null); - window.close(); + closeAfterApproval(navigate); } async function handleSign() { await send(INTERNAL_METHODS.APPROVE_SIGN_MESSAGE, [id]); setPendingSignRequest(null); - window.close(); + closeAfterApproval(navigate); } const bg = 'var(--color-bg)'; @@ -40,7 +41,7 @@ export function SignMessageScreen() {
{/* Header */}
diff --git a/extension/popup/screens/approvals/SignRawTxScreen.tsx b/extension/popup/screens/approvals/SignRawTxScreen.tsx index db7a7818..9c5c4b20 100644 --- a/extension/popup/screens/approvals/SignRawTxScreen.tsx +++ b/extension/popup/screens/approvals/SignRawTxScreen.tsx @@ -4,6 +4,7 @@ import { INTERNAL_METHODS, APPROVAL_CONSTANTS } from '../../../shared/constants' import { send } from '../../utils/messaging'; import { SignRawTxRequest } from '../../../shared/types'; import { useAutoRejectOnClose } from '../../hooks/useAutoRejectOnClose'; +import { closeAfterApproval } from '../../utils/displayContext'; import { AccountIcon } from '../../components/AccountIcon'; import { SiteIcon } from '../../components/SiteIcon'; import { truncateAddress } from '../../utils/format'; @@ -97,13 +98,13 @@ export function SignRawTxScreen() { async function handleDecline() { await send(INTERNAL_METHODS.REJECT_SIGN_RAW_TX, [id]); setPendingSignRawTxRequest(null); - window.close(); + closeAfterApproval(navigate); } async function handleSign() { await send(INTERNAL_METHODS.APPROVE_SIGN_RAW_TX, [id]); setPendingSignRawTxRequest(null); - window.close(); + closeAfterApproval(navigate); } // Network fee from native rawTx. RawTxV1.spends is a ZMap @@ -139,7 +140,7 @@ export function SignRawTxScreen() {
{/* Header */}
diff --git a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx index 753211e7..426c066d 100644 --- a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +++ b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx @@ -7,6 +7,7 @@ import { send } from '../../utils/messaging'; import { INTERNAL_METHODS, NOCK_TO_NICKS } from '../../../shared/constants'; import { formatNock, formatNick } from '../../../shared/currency'; import { useAutoRejectOnClose } from '../../hooks/useAutoRejectOnClose'; +import { closeAfterApproval } from '../../utils/displayContext'; export function TransactionApprovalScreen() { const { navigate, pendingTransactionRequest, setPendingTransactionRequest, wallet } = useStore(); @@ -28,13 +29,13 @@ export function TransactionApprovalScreen() { async function handleReject() { await send(INTERNAL_METHODS.REJECT_TRANSACTION, [id]); setPendingTransactionRequest(null); - window.close(); + closeAfterApproval(navigate); } async function handleApprove() { await send(INTERNAL_METHODS.APPROVE_TRANSACTION, [id]); setPendingTransactionRequest(null); - window.close(); + closeAfterApproval(navigate); } const bg = 'var(--color-bg)'; @@ -47,7 +48,7 @@ export function TransactionApprovalScreen() {
{/* Header */}
diff --git a/extension/popup/screens/onboarding/BackupScreen.tsx b/extension/popup/screens/onboarding/BackupScreen.tsx index f64c351a..61de11d7 100644 --- a/extension/popup/screens/onboarding/BackupScreen.tsx +++ b/extension/popup/screens/onboarding/BackupScreen.tsx @@ -30,7 +30,7 @@ export function BackupScreen() { if (!onboardingMnemonic) { // Should never happen, but handle gracefully return ( -
+
No mnemonic found. Please restart onboarding.
{showPopupHint && ( -
- Pop-up mode is active. Click the Iris icon in the toolbar to open the wallet in a pop-up window. +
+ Pop-up mode is active. Click the Iris icon in the toolbar to open the wallet in a pop-up + window.
)} diff --git a/extension/popup/screens/ReceiveScreen.tsx b/extension/popup/screens/ReceiveScreen.tsx index b179981d..000e525c 100644 --- a/extension/popup/screens/ReceiveScreen.tsx +++ b/extension/popup/screens/ReceiveScreen.tsx @@ -53,7 +53,10 @@ export function ReceiveScreen() { {/* Content */} -
+
{/* Intro */}
diff --git a/extension/popup/screens/SendSubmittedScreen.tsx b/extension/popup/screens/SendSubmittedScreen.tsx index f33a7bbb..7bc8d880 100644 --- a/extension/popup/screens/SendSubmittedScreen.tsx +++ b/extension/popup/screens/SendSubmittedScreen.tsx @@ -104,10 +104,7 @@ export function SendSubmittedScreen() { } return ( -
+
{/* Header */}
-
+
{/* Header */}

diff --git a/extension/popup/screens/approvals/SignMessageScreen.tsx b/extension/popup/screens/approvals/SignMessageScreen.tsx index 5a3528a7..92e69b2f 100644 --- a/extension/popup/screens/approvals/SignMessageScreen.tsx +++ b/extension/popup/screens/approvals/SignMessageScreen.tsx @@ -39,10 +39,7 @@ export function SignMessageScreen() { return (
-
+
{/* Header */}

diff --git a/extension/popup/screens/approvals/SignRawTxScreen.tsx b/extension/popup/screens/approvals/SignRawTxScreen.tsx index 9c5c4b20..de18945e 100644 --- a/extension/popup/screens/approvals/SignRawTxScreen.tsx +++ b/extension/popup/screens/approvals/SignRawTxScreen.tsx @@ -138,10 +138,7 @@ export function SignRawTxScreen() { return (
-
+
{/* Header */}

diff --git a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx index 426c066d..fc08ba5a 100644 --- a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +++ b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx @@ -46,10 +46,7 @@ export function TransactionApprovalScreen() { return (
-
+
{/* Header */}

diff --git a/extension/shared/pending-approvals-session.ts b/extension/shared/pending-approvals-session.ts index f56ce58f..97ceccf5 100644 --- a/extension/shared/pending-approvals-session.ts +++ b/extension/shared/pending-approvals-session.ts @@ -1,11 +1,6 @@ import { SESSION_STORAGE_KEYS } from './constants'; import type { ApprovalType } from './constants'; -import type { - ConnectRequest, - SignRequest, - TransactionRequest, - SignRawTxRequest, -} from './types'; +import type { ConnectRequest, SignRequest, TransactionRequest, SignRawTxRequest } from './types'; type PersistableRequest = ConnectRequest | SignRequest | TransactionRequest; @@ -60,8 +55,7 @@ export function buildPendingApprovalSessionSnapshot( const queue = requestQueue.filter(item => pending[item.id]); - const activeCurrentId = - currentRequestId && pending[currentRequestId] ? currentRequestId : null; + const activeCurrentId = currentRequestId && pending[currentRequestId] ? currentRequestId : null; const activeCurrentType = activeCurrentId ? currentRequestType : null; if (!activeCurrentId && queue.length === 0 && Object.keys(pending).length === 0) { From d411da4042be681674f0f8988fc14ffe9bbb2653 Mon Sep 17 00:00:00 2001 From: thomasurbit <99678438+thomasurbit@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:39:53 +0200 Subject: [PATCH 07/14] fix: open side panel via synchronous gesture hook in background chrome.sidePanel and chrome.tabs are unavailable in content scripts, so the previous content-script open call never ran. Open the panel synchronously in the onMessage listener instead, where Chrome preserves the dApp click gesture (any await consumes it), using an in-memory display mode cache. Co-authored-by: Cursor --- extension/background/index.ts | 41 +++++++++++++++++++++++++++++++++- extension/content/index.ts | 22 ------------------ extension/shared/side-panel.ts | 33 +-------------------------- 3 files changed, 41 insertions(+), 55 deletions(-) diff --git a/extension/background/index.ts b/extension/background/index.ts index 12220110..0c42b95d 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -45,7 +45,7 @@ import type { SignRawTxRequest, WalletTransaction, } from '../shared/types'; -import { SIDE_PANEL_DEFAULT_PATH } from '../shared/side-panel'; +import { SIDE_PANEL_DEFAULT_PATH, APPROVAL_PROVIDER_METHODS } from '../shared/side-panel'; import { buildPendingApprovalSessionSnapshot, persistPendingApprovalSession, @@ -65,6 +65,12 @@ let requestQueue: Array<{ type: 'connect' | 'transaction' | 'sign-message' | 'sign-raw-tx'; }> = []; // Queued requests +/** + * In-memory display mode cache so the user-gesture side panel hook can run + * synchronously inside the onMessage listener (any await loses the gesture). + */ +let cachedDisplayMode: DisplayMode = DISPLAY_MODES.POPUP; + /** * In-memory cache of approved origins * Loaded from storage on startup, persisted on changes @@ -544,6 +550,8 @@ async function applyDisplayMode(mode: DisplayMode): Promise { ? DISPLAY_MODES.SIDE_PANEL : DISPLAY_MODES.POPUP; + cachedDisplayMode = effectiveMode; + if (effectiveMode === DISPLAY_MODES.SIDE_PANEL) { await chrome.action.setPopup({ popup: '' }); await chrome.sidePanel!.setPanelBehavior({ openPanelOnActionClick: true }); @@ -948,10 +956,41 @@ function isFromPopup(sender: chrome.runtime.MessageSender): boolean { return url.startsWith(`chrome-extension://${extensionId}/`); } +/** + * Synchronous user-gesture hook: open the side panel for approval-bearing + * provider requests while the gesture from the dApp click is still active. + * Must run before any await in the listener — awaiting consumes the gesture. + */ +function maybeOpenSidePanelOnGesture(msg: any, sender: chrome.runtime.MessageSender): void { + if (cachedDisplayMode !== DISPLAY_MODES.SIDE_PANEL || !chrome.sidePanel) { + return; + } + + const tabId = sender.tab?.id; + if (tabId === undefined || isFromPopup(sender)) { + return; + } + + const method = msg?.payload?.method; + if ( + typeof method !== 'string' || + (!APPROVAL_PROVIDER_METHODS.has(method) && method !== 'nock_signRawTx') + ) { + return; + } + + chrome.sidePanel.open({ tabId }).catch(() => { + // Gesture may have been consumed or panel already open; background + // routing via APPROVAL_PENDING still delivers the request. + }); +} + /** * Handle messages from content script and popup */ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + maybeOpenSidePanelOnGesture(msg, _sender); + (async () => { await initPromise; await ensureSessionRestored(); diff --git a/extension/content/index.ts b/extension/content/index.ts index f6f1ad87..0124ec6c 100644 --- a/extension/content/index.ts +++ b/extension/content/index.ts @@ -6,16 +6,6 @@ */ import { MESSAGE_TARGETS } from '../shared/constants'; -import { APPROVAL_PROVIDER_METHODS, openSidePanelFromUserGesture } from '../shared/side-panel'; - -function getProviderMethod(payload: unknown): string | undefined { - if (!payload || typeof payload !== 'object' || !('method' in payload)) { - return undefined; - } - - const method = (payload as { method?: unknown }).method; - return typeof method === 'string' ? method : undefined; -} /** * Bridge page <-> Service Worker @@ -34,18 +24,6 @@ window.addEventListener('message', async (evt: MessageEvent) => { return; } - const method = getProviderMethod(data.payload); - if (method && APPROVAL_PROVIDER_METHODS.has(method)) { - try { - const tab = await chrome.tabs.getCurrent(); - if (tab?.id !== undefined) { - await openSidePanelFromUserGesture(tab.id); - } - } catch (error) { - console.warn('[Iris] Failed to open side panel before provider request:', error); - } - } - // Forward to service worker and relay response back to page const reply = await chrome.runtime.sendMessage(data); diff --git a/extension/shared/side-panel.ts b/extension/shared/side-panel.ts index 4d6252a6..05d54e0b 100644 --- a/extension/shared/side-panel.ts +++ b/extension/shared/side-panel.ts @@ -1,4 +1,4 @@ -import { DISPLAY_MODES, PROVIDER_METHODS, STORAGE_KEYS } from './constants'; +import { PROVIDER_METHODS } from './constants'; export const SIDE_PANEL_DEFAULT_PATH = 'sidepanel/index.html'; @@ -9,34 +9,3 @@ export const APPROVAL_PROVIDER_METHODS = new Set([ PROVIDER_METHODS.SEND_TRANSACTION, PROVIDER_METHODS.SIGN_TX, ]); - -export async function isSidePanelDisplayMode(): Promise { - if (!chrome.sidePanel) { - return false; - } - - const stored = await chrome.storage.local.get([STORAGE_KEYS.DISPLAY_MODE]); - return stored[STORAGE_KEYS.DISPLAY_MODE] === DISPLAY_MODES.SIDE_PANEL; -} - -/** Open the wallet side panel while the user-gesture chain is still active. */ -export async function openSidePanelFromUserGesture(tabId: number): Promise { - if (!chrome.sidePanel) { - return; - } - - if (!(await isSidePanelDisplayMode())) { - return; - } - - try { - await chrome.sidePanel.setOptions({ - tabId, - path: SIDE_PANEL_DEFAULT_PATH, - enabled: true, - }); - await chrome.sidePanel.open({ tabId }); - } catch (error) { - console.warn('[Iris] sidePanel.open from user gesture failed:', error); - } -} From 302f1669ba9b0878f2b555cf2d7c2b55fae53631 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 11:10:31 +0000 Subject: [PATCH 08/14] feat: replace wallet icon pack and rework style assignment logic - Replace the 15 numbered wallet icons with a new 29-icon pack: 5 kept classics (styles 1, 2, 3, 8, 14 - default unchanged) plus 24 new iris-themed icons, normalised to the shared tintable-SVG convention (var(--fill-0) placeholder, container-sized root). - Add shared/walletStyles.ts as the single source of truth: icon registry with visual families (picker groups similar icons together), a 17-colour palette (7 existing + 10 new in the same poppy scheme), and a deterministic assignment sequence that round-robins across icon families and strides the colour rainbow so consecutive wallets always look distinct; combinations only repeat after all 493 are used. - Switch persisted iconStyleId to stable string ids with transparent migration of legacy numeric ids (retired styles fall back to default). - Drive AccountIcon and WalletStylingScreen from the shared registry. https://claude.ai/code/session_01EPxCTHkLN8QM5iuNR5qqrn --- extension/popup/assets/wallet-icon-bolt.svg | 4 + .../popup/assets/wallet-icon-cat-eye.svg | 4 + .../popup/assets/wallet-icon-fibonacci.svg | 4 + .../popup/assets/wallet-icon-gaze-down.svg | 4 + .../popup/assets/wallet-icon-gaze-left.svg | 4 + .../popup/assets/wallet-icon-gaze-right.svg | 4 + extension/popup/assets/wallet-icon-gem.svg | 4 + extension/popup/assets/wallet-icon-heart.svg | 4 + .../popup/assets/wallet-icon-keyhole.svg | 4 + extension/popup/assets/wallet-icon-moon.svg | 4 + .../popup/assets/wallet-icon-nock-n-round.svg | 4 + extension/popup/assets/wallet-icon-nock-n.svg | 4 + .../popup/assets/wallet-icon-north-star.svg | 4 + extension/popup/assets/wallet-icon-orbit.svg | 4 + .../popup/assets/wallet-icon-pinwheel-sun.svg | 4 + .../assets/wallet-icon-quatrefoil-eye.svg | 4 + extension/popup/assets/wallet-icon-ring.svg | 4 + .../popup/assets/wallet-icon-seal-eye.svg | 4 + .../assets/wallet-icon-seal-fibonacci.svg | 4 + .../popup/assets/wallet-icon-snowflake.svg | 4 + extension/popup/assets/wallet-icon-spiral.svg | 4 + .../popup/assets/wallet-icon-squircle-eye.svg | 4 + .../popup/assets/wallet-icon-style-10.svg | 8 - .../popup/assets/wallet-icon-style-11.svg | 8 - .../popup/assets/wallet-icon-style-12.svg | 8 - .../popup/assets/wallet-icon-style-13.svg | 8 - .../popup/assets/wallet-icon-style-15.svg | 8 - .../popup/assets/wallet-icon-style-4.svg | 8 - .../popup/assets/wallet-icon-style-5.svg | 8 - .../popup/assets/wallet-icon-style-6.svg | 8 - .../popup/assets/wallet-icon-style-7.svg | 8 - .../popup/assets/wallet-icon-style-9.svg | 8 - extension/popup/assets/wallet-icon-sun.svg | 4 + extension/popup/assets/wallet-icon-target.svg | 4 + extension/popup/components/AccountIcon.tsx | 52 ++---- .../popup/components/walletIconAssets.ts | 67 +++++++ .../popup/screens/WalletStylingScreen.tsx | 61 ++----- extension/shared/constants.ts | 42 ----- extension/shared/types.ts | 4 +- extension/shared/vault.ts | 50 +++--- extension/shared/walletStyles.ts | 169 ++++++++++++++++++ 41 files changed, 391 insertions(+), 230 deletions(-) create mode 100644 extension/popup/assets/wallet-icon-bolt.svg create mode 100644 extension/popup/assets/wallet-icon-cat-eye.svg create mode 100644 extension/popup/assets/wallet-icon-fibonacci.svg create mode 100644 extension/popup/assets/wallet-icon-gaze-down.svg create mode 100644 extension/popup/assets/wallet-icon-gaze-left.svg create mode 100644 extension/popup/assets/wallet-icon-gaze-right.svg create mode 100644 extension/popup/assets/wallet-icon-gem.svg create mode 100644 extension/popup/assets/wallet-icon-heart.svg create mode 100644 extension/popup/assets/wallet-icon-keyhole.svg create mode 100644 extension/popup/assets/wallet-icon-moon.svg create mode 100644 extension/popup/assets/wallet-icon-nock-n-round.svg create mode 100644 extension/popup/assets/wallet-icon-nock-n.svg create mode 100644 extension/popup/assets/wallet-icon-north-star.svg create mode 100644 extension/popup/assets/wallet-icon-orbit.svg create mode 100644 extension/popup/assets/wallet-icon-pinwheel-sun.svg create mode 100644 extension/popup/assets/wallet-icon-quatrefoil-eye.svg create mode 100644 extension/popup/assets/wallet-icon-ring.svg create mode 100644 extension/popup/assets/wallet-icon-seal-eye.svg create mode 100644 extension/popup/assets/wallet-icon-seal-fibonacci.svg create mode 100644 extension/popup/assets/wallet-icon-snowflake.svg create mode 100644 extension/popup/assets/wallet-icon-spiral.svg create mode 100644 extension/popup/assets/wallet-icon-squircle-eye.svg delete mode 100644 extension/popup/assets/wallet-icon-style-10.svg delete mode 100644 extension/popup/assets/wallet-icon-style-11.svg delete mode 100644 extension/popup/assets/wallet-icon-style-12.svg delete mode 100644 extension/popup/assets/wallet-icon-style-13.svg delete mode 100644 extension/popup/assets/wallet-icon-style-15.svg delete mode 100644 extension/popup/assets/wallet-icon-style-4.svg delete mode 100644 extension/popup/assets/wallet-icon-style-5.svg delete mode 100644 extension/popup/assets/wallet-icon-style-6.svg delete mode 100644 extension/popup/assets/wallet-icon-style-7.svg delete mode 100644 extension/popup/assets/wallet-icon-style-9.svg create mode 100644 extension/popup/assets/wallet-icon-sun.svg create mode 100644 extension/popup/assets/wallet-icon-target.svg create mode 100644 extension/popup/components/walletIconAssets.ts create mode 100644 extension/shared/walletStyles.ts diff --git a/extension/popup/assets/wallet-icon-bolt.svg b/extension/popup/assets/wallet-icon-bolt.svg new file mode 100644 index 00000000..c28760ef --- /dev/null +++ b/extension/popup/assets/wallet-icon-bolt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-cat-eye.svg b/extension/popup/assets/wallet-icon-cat-eye.svg new file mode 100644 index 00000000..a04cd3a1 --- /dev/null +++ b/extension/popup/assets/wallet-icon-cat-eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-fibonacci.svg b/extension/popup/assets/wallet-icon-fibonacci.svg new file mode 100644 index 00000000..61900b4d --- /dev/null +++ b/extension/popup/assets/wallet-icon-fibonacci.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-gaze-down.svg b/extension/popup/assets/wallet-icon-gaze-down.svg new file mode 100644 index 00000000..def3a29c --- /dev/null +++ b/extension/popup/assets/wallet-icon-gaze-down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-gaze-left.svg b/extension/popup/assets/wallet-icon-gaze-left.svg new file mode 100644 index 00000000..4c7c12a6 --- /dev/null +++ b/extension/popup/assets/wallet-icon-gaze-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-gaze-right.svg b/extension/popup/assets/wallet-icon-gaze-right.svg new file mode 100644 index 00000000..d1614736 --- /dev/null +++ b/extension/popup/assets/wallet-icon-gaze-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-gem.svg b/extension/popup/assets/wallet-icon-gem.svg new file mode 100644 index 00000000..c3488688 --- /dev/null +++ b/extension/popup/assets/wallet-icon-gem.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-heart.svg b/extension/popup/assets/wallet-icon-heart.svg new file mode 100644 index 00000000..da9b57cd --- /dev/null +++ b/extension/popup/assets/wallet-icon-heart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-keyhole.svg b/extension/popup/assets/wallet-icon-keyhole.svg new file mode 100644 index 00000000..9126b362 --- /dev/null +++ b/extension/popup/assets/wallet-icon-keyhole.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-moon.svg b/extension/popup/assets/wallet-icon-moon.svg new file mode 100644 index 00000000..1cd469b0 --- /dev/null +++ b/extension/popup/assets/wallet-icon-moon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-nock-n-round.svg b/extension/popup/assets/wallet-icon-nock-n-round.svg new file mode 100644 index 00000000..1d1cdf97 --- /dev/null +++ b/extension/popup/assets/wallet-icon-nock-n-round.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-nock-n.svg b/extension/popup/assets/wallet-icon-nock-n.svg new file mode 100644 index 00000000..39e41d8e --- /dev/null +++ b/extension/popup/assets/wallet-icon-nock-n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-north-star.svg b/extension/popup/assets/wallet-icon-north-star.svg new file mode 100644 index 00000000..9c39eb85 --- /dev/null +++ b/extension/popup/assets/wallet-icon-north-star.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-orbit.svg b/extension/popup/assets/wallet-icon-orbit.svg new file mode 100644 index 00000000..170156f1 --- /dev/null +++ b/extension/popup/assets/wallet-icon-orbit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-pinwheel-sun.svg b/extension/popup/assets/wallet-icon-pinwheel-sun.svg new file mode 100644 index 00000000..c282095e --- /dev/null +++ b/extension/popup/assets/wallet-icon-pinwheel-sun.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-quatrefoil-eye.svg b/extension/popup/assets/wallet-icon-quatrefoil-eye.svg new file mode 100644 index 00000000..6ffd6fcc --- /dev/null +++ b/extension/popup/assets/wallet-icon-quatrefoil-eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-ring.svg b/extension/popup/assets/wallet-icon-ring.svg new file mode 100644 index 00000000..58ad8a52 --- /dev/null +++ b/extension/popup/assets/wallet-icon-ring.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-seal-eye.svg b/extension/popup/assets/wallet-icon-seal-eye.svg new file mode 100644 index 00000000..248b5b83 --- /dev/null +++ b/extension/popup/assets/wallet-icon-seal-eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-seal-fibonacci.svg b/extension/popup/assets/wallet-icon-seal-fibonacci.svg new file mode 100644 index 00000000..2c687caf --- /dev/null +++ b/extension/popup/assets/wallet-icon-seal-fibonacci.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-snowflake.svg b/extension/popup/assets/wallet-icon-snowflake.svg new file mode 100644 index 00000000..e40e4478 --- /dev/null +++ b/extension/popup/assets/wallet-icon-snowflake.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-spiral.svg b/extension/popup/assets/wallet-icon-spiral.svg new file mode 100644 index 00000000..16f1cceb --- /dev/null +++ b/extension/popup/assets/wallet-icon-spiral.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-squircle-eye.svg b/extension/popup/assets/wallet-icon-squircle-eye.svg new file mode 100644 index 00000000..965d6a90 --- /dev/null +++ b/extension/popup/assets/wallet-icon-squircle-eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-style-10.svg b/extension/popup/assets/wallet-icon-style-10.svg deleted file mode 100644 index 4b3cc77a..00000000 --- a/extension/popup/assets/wallet-icon-style-10.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-11.svg b/extension/popup/assets/wallet-icon-style-11.svg deleted file mode 100644 index de4b5a4e..00000000 --- a/extension/popup/assets/wallet-icon-style-11.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-12.svg b/extension/popup/assets/wallet-icon-style-12.svg deleted file mode 100644 index 742646b3..00000000 --- a/extension/popup/assets/wallet-icon-style-12.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-13.svg b/extension/popup/assets/wallet-icon-style-13.svg deleted file mode 100644 index 96e8dc42..00000000 --- a/extension/popup/assets/wallet-icon-style-13.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-15.svg b/extension/popup/assets/wallet-icon-style-15.svg deleted file mode 100644 index 88489601..00000000 --- a/extension/popup/assets/wallet-icon-style-15.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-4.svg b/extension/popup/assets/wallet-icon-style-4.svg deleted file mode 100644 index d2fe810f..00000000 --- a/extension/popup/assets/wallet-icon-style-4.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-5.svg b/extension/popup/assets/wallet-icon-style-5.svg deleted file mode 100644 index 893a774c..00000000 --- a/extension/popup/assets/wallet-icon-style-5.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-6.svg b/extension/popup/assets/wallet-icon-style-6.svg deleted file mode 100644 index 7654a851..00000000 --- a/extension/popup/assets/wallet-icon-style-6.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-7.svg b/extension/popup/assets/wallet-icon-style-7.svg deleted file mode 100644 index 6370838a..00000000 --- a/extension/popup/assets/wallet-icon-style-7.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-style-9.svg b/extension/popup/assets/wallet-icon-style-9.svg deleted file mode 100644 index 26734485..00000000 --- a/extension/popup/assets/wallet-icon-style-9.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - diff --git a/extension/popup/assets/wallet-icon-sun.svg b/extension/popup/assets/wallet-icon-sun.svg new file mode 100644 index 00000000..8d752998 --- /dev/null +++ b/extension/popup/assets/wallet-icon-sun.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/wallet-icon-target.svg b/extension/popup/assets/wallet-icon-target.svg new file mode 100644 index 00000000..2516d61c --- /dev/null +++ b/extension/popup/assets/wallet-icon-target.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/components/AccountIcon.tsx b/extension/popup/components/AccountIcon.tsx index 0ed5b481..07675006 100644 --- a/extension/popup/components/AccountIcon.tsx +++ b/extension/popup/components/AccountIcon.tsx @@ -1,43 +1,11 @@ import { useEffect, useState } from 'react'; -// Import all wallet icon styles -import WalletStyle1 from '../assets/wallet-icon-style-1.svg'; -import WalletStyle2 from '../assets/wallet-icon-style-2.svg'; -import WalletStyle3 from '../assets/wallet-icon-style-3.svg'; -import WalletStyle4 from '../assets/wallet-icon-style-4.svg'; -import WalletStyle5 from '../assets/wallet-icon-style-5.svg'; -import WalletStyle6 from '../assets/wallet-icon-style-6.svg'; -import WalletStyle7 from '../assets/wallet-icon-style-7.svg'; -import WalletStyle8 from '../assets/wallet-icon-style-8.svg'; -import WalletStyle9 from '../assets/wallet-icon-style-9.svg'; -import WalletStyle10 from '../assets/wallet-icon-style-10.svg'; -import WalletStyle11 from '../assets/wallet-icon-style-11.svg'; -import WalletStyle12 from '../assets/wallet-icon-style-12.svg'; -import WalletStyle13 from '../assets/wallet-icon-style-13.svg'; -import WalletStyle14 from '../assets/wallet-icon-style-14.svg'; -import WalletStyle15 from '../assets/wallet-icon-style-15.svg'; - -const iconStyles = [ - { id: 1, icon: WalletStyle1 }, - { id: 2, icon: WalletStyle2 }, - { id: 3, icon: WalletStyle3 }, - { id: 4, icon: WalletStyle4 }, - { id: 5, icon: WalletStyle5 }, - { id: 6, icon: WalletStyle6 }, - { id: 7, icon: WalletStyle7 }, - { id: 8, icon: WalletStyle8 }, - { id: 9, icon: WalletStyle9 }, - { id: 10, icon: WalletStyle10 }, - { id: 11, icon: WalletStyle11 }, - { id: 12, icon: WalletStyle12 }, - { id: 13, icon: WalletStyle13 }, - { id: 14, icon: WalletStyle14 }, - { id: 15, icon: WalletStyle15 }, -]; +import { DEFAULT_WALLET_STYLE, normalizeIconStyleId } from '../../shared/walletStyles'; +import { WALLET_ICON_ASSETS } from './walletIconAssets'; interface AccountIconProps { - /** Icon style ID (1-15) */ - styleId?: number; + /** Icon style id (slug; legacy numeric ids are mapped automatically) */ + styleId?: number | string; /** Icon color (hex string) */ color?: string; /** CSS class names */ @@ -49,16 +17,18 @@ interface AccountIconProps { * Fetches the SVG and applies the color dynamically */ export function AccountIcon({ - styleId = 1, - color = '#FFC413', + styleId, + color = DEFAULT_WALLET_STYLE.iconColor, className = 'h-6 w-6', }: AccountIconProps) { const [svgContent, setSvgContent] = useState(''); + const iconId = normalizeIconStyleId(styleId); + useEffect(() => { - const selectedIcon = iconStyles.find(s => s.id === styleId) || iconStyles[0]; + const asset = WALLET_ICON_ASSETS[iconId]; - fetch(selectedIcon.icon) + fetch(asset) .then(res => res.text()) .then(text => { // Replace CSS var `--fill-0` with the chosen color @@ -72,7 +42,7 @@ export function AccountIcon({ `` ); }); - }, [styleId, color]); + }, [iconId, color]); return
; } diff --git a/extension/popup/components/walletIconAssets.ts b/extension/popup/components/walletIconAssets.ts new file mode 100644 index 00000000..2bf4fd6b --- /dev/null +++ b/extension/popup/components/walletIconAssets.ts @@ -0,0 +1,67 @@ +/** + * Maps wallet icon style ids (see shared/walletStyles.ts) to their SVG assets. + * Every asset uses the `var(--fill-0, #FFC413)` placeholder so it can be + * tinted with any account color at render time. + */ + +import Style1 from '../assets/wallet-icon-style-1.svg'; +import Style2 from '../assets/wallet-icon-style-2.svg'; +import Style3 from '../assets/wallet-icon-style-3.svg'; +import Style8 from '../assets/wallet-icon-style-8.svg'; +import Style14 from '../assets/wallet-icon-style-14.svg'; +import GazeLeft from '../assets/wallet-icon-gaze-left.svg'; +import GazeRight from '../assets/wallet-icon-gaze-right.svg'; +import GazeDown from '../assets/wallet-icon-gaze-down.svg'; +import SquircleEye from '../assets/wallet-icon-squircle-eye.svg'; +import SealEye from '../assets/wallet-icon-seal-eye.svg'; +import QuatrefoilEye from '../assets/wallet-icon-quatrefoil-eye.svg'; +import CatEye from '../assets/wallet-icon-cat-eye.svg'; +import Ring from '../assets/wallet-icon-ring.svg'; +import Target from '../assets/wallet-icon-target.svg'; +import Sun from '../assets/wallet-icon-sun.svg'; +import PinwheelSun from '../assets/wallet-icon-pinwheel-sun.svg'; +import Moon from '../assets/wallet-icon-moon.svg'; +import NorthStar from '../assets/wallet-icon-north-star.svg'; +import Orbit from '../assets/wallet-icon-orbit.svg'; +import Spiral from '../assets/wallet-icon-spiral.svg'; +import Fibonacci from '../assets/wallet-icon-fibonacci.svg'; +import SealFibonacci from '../assets/wallet-icon-seal-fibonacci.svg'; +import Snowflake from '../assets/wallet-icon-snowflake.svg'; +import Gem from '../assets/wallet-icon-gem.svg'; +import Heart from '../assets/wallet-icon-heart.svg'; +import Bolt from '../assets/wallet-icon-bolt.svg'; +import Keyhole from '../assets/wallet-icon-keyhole.svg'; +import NockN from '../assets/wallet-icon-nock-n.svg'; +import NockNRound from '../assets/wallet-icon-nock-n-round.svg'; + +export const WALLET_ICON_ASSETS: Record = { + 'style-1': Style1, + 'style-2': Style2, + 'style-3': Style3, + 'style-8': Style8, + 'style-14': Style14, + 'gaze-left': GazeLeft, + 'gaze-right': GazeRight, + 'gaze-down': GazeDown, + 'squircle-eye': SquircleEye, + 'seal-eye': SealEye, + 'quatrefoil-eye': QuatrefoilEye, + 'cat-eye': CatEye, + ring: Ring, + target: Target, + sun: Sun, + 'pinwheel-sun': PinwheelSun, + moon: Moon, + 'north-star': NorthStar, + orbit: Orbit, + spiral: Spiral, + fibonacci: Fibonacci, + 'seal-fibonacci': SealFibonacci, + snowflake: Snowflake, + gem: Gem, + heart: Heart, + bolt: Bolt, + keyhole: Keyhole, + 'nock-n': NockN, + 'nock-n-round': NockNRound, +}; diff --git a/extension/popup/screens/WalletStylingScreen.tsx b/extension/popup/screens/WalletStylingScreen.tsx index c3d54710..7d1d76e5 100644 --- a/extension/popup/screens/WalletStylingScreen.tsx +++ b/extension/popup/screens/WalletStylingScreen.tsx @@ -1,27 +1,17 @@ import { useState, useEffect, useRef } from 'react'; import { useStore } from '../store'; import { send } from '../utils/messaging'; -import { INTERNAL_METHODS, ACCOUNT_COLORS } from '../../shared/constants'; +import { INTERNAL_METHODS } from '../../shared/constants'; +import { + ACCOUNT_COLORS, + DEFAULT_WALLET_STYLE, + WALLET_ICONS, + normalizeIconStyleId, +} from '../../shared/walletStyles'; +import { WALLET_ICON_ASSETS } from '../components/walletIconAssets'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { ChevronRightIcon } from '../components/icons/ChevronRightIcon'; -// Icons -import WalletStyle1 from '../assets/wallet-icon-style-1.svg'; -import WalletStyle2 from '../assets/wallet-icon-style-2.svg'; -import WalletStyle3 from '../assets/wallet-icon-style-3.svg'; -import WalletStyle4 from '../assets/wallet-icon-style-4.svg'; -import WalletStyle5 from '../assets/wallet-icon-style-5.svg'; -import WalletStyle6 from '../assets/wallet-icon-style-6.svg'; -import WalletStyle7 from '../assets/wallet-icon-style-7.svg'; -import WalletStyle8 from '../assets/wallet-icon-style-8.svg'; -import WalletStyle9 from '../assets/wallet-icon-style-9.svg'; -import WalletStyle10 from '../assets/wallet-icon-style-10.svg'; -import WalletStyle11 from '../assets/wallet-icon-style-11.svg'; -import WalletStyle12 from '../assets/wallet-icon-style-12.svg'; -import WalletStyle13 from '../assets/wallet-icon-style-13.svg'; -import WalletStyle14 from '../assets/wallet-icon-style-14.svg'; -import WalletStyle15 from '../assets/wallet-icon-style-15.svg'; - export function WalletStylingScreen() { const { navigate, wallet, refreshWalletAccounts, settingsAccountAddress } = useStore(); @@ -35,40 +25,27 @@ export function WalletStylingScreen() { wallet.accounts[0]; // Load initial values from current account or use defaults - const [selectedStyle, setSelectedStyle] = useState(currentAccount?.iconStyleId || 1); - const [selectedColor, setSelectedColor] = useState(currentAccount?.iconColor || '#FFC413'); + const [selectedStyle, setSelectedStyle] = useState( + normalizeIconStyleId(currentAccount?.iconStyleId) + ); + const [selectedColor, setSelectedColor] = useState( + currentAccount?.iconColor || DEFAULT_WALLET_STYLE.iconColor + ); const [svgContent, setSvgContent] = useState(''); // Track if we're scrolled to the end (false = at start, true = at end) const [isScrolledRight, setIsScrolledRight] = useState(false); const colorScrollRef = useRef(null); - const iconStyles = [ - { id: 1, icon: WalletStyle1 }, - { id: 2, icon: WalletStyle2 }, - { id: 3, icon: WalletStyle3 }, - { id: 4, icon: WalletStyle4 }, - { id: 5, icon: WalletStyle5 }, - { id: 6, icon: WalletStyle6 }, - { id: 7, icon: WalletStyle7 }, - { id: 8, icon: WalletStyle8 }, - { id: 9, icon: WalletStyle9 }, - { id: 10, icon: WalletStyle10 }, - { id: 11, icon: WalletStyle11 }, - { id: 12, icon: WalletStyle12 }, - { id: 13, icon: WalletStyle13 }, - { id: 14, icon: WalletStyle14 }, - { id: 15, icon: WalletStyle15 }, - ]; - - // Use shared color constants + // Shared icon registry (picker order: similar styles grouped) and color palette + const iconStyles = WALLET_ICONS.map(icon => ({ id: icon.id, icon: WALLET_ICON_ASSETS[icon.id] })); const colors = ACCOUNT_COLORS; // Sync state when current account changes useEffect(() => { if (currentAccount) { - setSelectedStyle(currentAccount.iconStyleId || 1); - const color = currentAccount.iconColor || '#FFC413'; + setSelectedStyle(normalizeIconStyleId(currentAccount.iconStyleId)); + const color = currentAccount.iconColor || DEFAULT_WALLET_STYLE.iconColor; setSelectedColor(color); } }, [currentAccount?.address, currentAccount?.iconStyleId, currentAccount?.iconColor]); @@ -89,7 +66,7 @@ export function WalletStylingScreen() { }, [selectedStyle, selectedColor]); // Persist styling changes - async function handleStyleChange(styleId: number) { + async function handleStyleChange(styleId: string) { if (!currentAccount) return; setSelectedStyle(styleId); diff --git a/extension/shared/constants.ts b/extension/shared/constants.ts index 202c20c6..9db016d6 100644 --- a/extension/shared/constants.ts +++ b/extension/shared/constants.ts @@ -404,48 +404,6 @@ export const UI_CONSTANTS = { MNEMONIC_WORD_COUNT: 24, } as const; -/** - * Account Icon Colors - Available colors for account customization - */ -export const ACCOUNT_COLORS = [ - '#2C9AEF', // blue - '#EF2C2F', // red - '#FFC413', // yellow (primary) - '#96B839', // green - '#3C2CEF', // purple - '#EF2CB1', // pink/magenta - '#2C6AEF', // darker blue -] as const; - -/** - * Preset Wallet Styles - Predetermined icon/color combinations for first 21 wallets - * Ensures visual variety without repeating until all presets are used - * After preset limit, random combinations are used - */ -export const PRESET_WALLET_STYLES = [ - { iconStyleId: 1, iconColor: '#FFC413' }, // Wallet 1: yellow, style 1 - { iconStyleId: 5, iconColor: '#2C9AEF' }, // Wallet 2: blue, style 5 - { iconStyleId: 9, iconColor: '#EF2C2F' }, // Wallet 3: red, style 9 - { iconStyleId: 3, iconColor: '#96B839' }, // Wallet 4: green, style 3 - { iconStyleId: 12, iconColor: '#3C2CEF' }, // Wallet 5: purple, style 12 - { iconStyleId: 7, iconColor: '#EF2CB1' }, // Wallet 6: pink, style 7 - { iconStyleId: 15, iconColor: '#2C6AEF' }, // Wallet 7: dark blue, style 15 - { iconStyleId: 2, iconColor: '#EF2C2F' }, // Wallet 8: red, style 2 - { iconStyleId: 6, iconColor: '#FFC413' }, // Wallet 9: yellow, style 6 - { iconStyleId: 10, iconColor: '#96B839' }, // Wallet 10: green, style 10 - { iconStyleId: 4, iconColor: '#2C9AEF' }, // Wallet 11: blue, style 4 - { iconStyleId: 13, iconColor: '#EF2CB1' }, // Wallet 12: pink, style 13 - { iconStyleId: 8, iconColor: '#3C2CEF' }, // Wallet 13: purple, style 8 - { iconStyleId: 14, iconColor: '#2C6AEF' }, // Wallet 14: dark blue, style 14 - { iconStyleId: 11, iconColor: '#EF2C2F' }, // Wallet 15: red, style 11 - { iconStyleId: 1, iconColor: '#96B839' }, // Wallet 16: green, style 1 - { iconStyleId: 5, iconColor: '#FFC413' }, // Wallet 17: yellow, style 5 - { iconStyleId: 9, iconColor: '#2C9AEF' }, // Wallet 18: blue, style 9 - { iconStyleId: 3, iconColor: '#3C2CEF' }, // Wallet 19: purple, style 3 - { iconStyleId: 12, iconColor: '#EF2CB1' }, // Wallet 20: pink, style 12 - { iconStyleId: 7, iconColor: '#2C6AEF' }, // Wallet 21: dark blue, style 7 -] as const; - /** * Approval Request Constants - URL hash prefixes for approval flows */ diff --git a/extension/shared/types.ts b/extension/shared/types.ts index 683ffeec..a2cc6593 100644 --- a/extension/shared/types.ts +++ b/extension/shared/types.ts @@ -17,8 +17,8 @@ export interface SubAccount { address: string; /** BIP-44 derivation index (0 = master/underived, 1+ = slip10 children) */ index: number; - /** Icon style ID (1-15, defaults to index % 3 + 1 for variety) */ - iconStyleId?: number; + /** Icon style id (slug, e.g. 'sun'); legacy vaults may hold numeric ids 1-15 */ + iconStyleId?: number | string; /** Icon color (hex string, defaults to #FFC413) */ iconColor?: string; /** Whether this account is hidden from the UI */ diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index 4cce04c0..e7b724d4 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -13,11 +13,16 @@ import { import { ERROR_CODES, STORAGE_KEYS, - ACCOUNT_COLORS, - PRESET_WALLET_STYLES, NOCK_TO_NICKS, MAX_SUBWALLET_DISCOVERY_SCAN, } from './constants'; +import { + DEFAULT_WALLET_STYLE, + TOTAL_STYLE_COMBINATIONS, + getPresetWalletStyle, + normalizeIconStyleId, + type WalletStyle, +} from './walletStyles'; import { SubAccount, SeedAccount } from './types'; import { buildMultiNotePayment, @@ -341,30 +346,29 @@ export class Vault { return (account?.index ?? -1) === 0; } - /** Returns a style (icon + color) not already used by any account across all seeds. */ - private pickUnusedStyleGlobally(): { iconStyleId: number; iconColor: string } { + /** + * Returns a style (icon + color) not already used by any account across all seeds. + * + * Walks the deterministic preset sequence (see `getPresetWalletStyle`) and + * returns the first combination still free, so new wallets get a varied but + * predictable look. Only once every combination is taken does it fall back + * to the default style. + */ + private pickUnusedStyleGlobally(): WalletStyle { const allAccounts = this.seedAccounts.flatMap(seed => seed.accounts); const usedKeys = new Set( allAccounts.map( - a => `${a.iconStyleId ?? 1}-${a.iconColor ?? PRESET_WALLET_STYLES[0].iconColor}` + a => + `${normalizeIconStyleId(a.iconStyleId)}-${a.iconColor ?? DEFAULT_WALLET_STYLE.iconColor}` ) ); - for (const preset of PRESET_WALLET_STYLES) { - const key = `${preset.iconStyleId}-${preset.iconColor}`; - if (!usedKeys.has(key)) { - return { iconStyleId: preset.iconStyleId, iconColor: preset.iconColor }; + for (let i = 0; i < TOTAL_STYLE_COMBINATIONS; i++) { + const preset = getPresetWalletStyle(i); + if (!usedKeys.has(`${preset.iconStyleId}-${preset.iconColor}`)) { + return preset; } } - // All presets used: pick random until we find an unused combo - const styleIds = Array.from({ length: 15 }, (_, i) => i + 1); - const colors = [...ACCOUNT_COLORS]; - for (let attempt = 0; attempt < 200; attempt++) { - const iconStyleId = styleIds[Math.floor(Math.random() * styleIds.length)]; - const iconColor = colors[Math.floor(Math.random() * colors.length)]; - const key = `${iconStyleId}-${iconColor}`; - if (!usedKeys.has(key)) return { iconStyleId, iconColor }; - } - return { iconStyleId: 1, iconColor: PRESET_WALLET_STYLES[0].iconColor }; + return { ...DEFAULT_WALLET_STYLE }; } private createSeedAccountFromLegacy(mnemonic: string, legacyAccounts: SubAccount[]): SeedAccount { @@ -537,8 +541,8 @@ export class Vault { } // Create first account (Wallet 1 at index 0) - // Use first preset style for consistent initial experience - const firstPreset = PRESET_WALLET_STYLES[0]; + // Use the default style for consistent initial experience + const firstPreset = DEFAULT_WALLET_STYLE; const masterAddress = await deriveAddressFromMaster(words); @@ -2797,7 +2801,7 @@ export class Vault { */ async updateAccountStyling( address: string, - iconStyleId: number, + iconStyleId: number | string, iconColor: string ): Promise<{ ok: boolean } | { error: string }> { if (this.state.locked) { @@ -2809,7 +2813,7 @@ export class Vault { return { error: ERROR_CODES.BAD_ADDRESS }; } - this.state.accounts[index].iconStyleId = iconStyleId; + this.state.accounts[index].iconStyleId = normalizeIconStyleId(iconStyleId); this.state.accounts[index].iconColor = iconColor; // Save accounts to encrypted vault diff --git a/extension/shared/walletStyles.ts b/extension/shared/walletStyles.ts new file mode 100644 index 00000000..72d92f92 --- /dev/null +++ b/extension/shared/walletStyles.ts @@ -0,0 +1,169 @@ +/** + * Wallet Styles - single source of truth for account icon styles and colors + * + * Icons are identified by stable string ids (e.g. 'sun', 'gaze-left') that are + * persisted on each account in the vault. Vaults written by older versions may + * still hold numeric ids (1-15); `normalizeIconStyleId` maps the surviving ones + * to their string id and retired ones to the default. + * + * Two orders are derived from the registry below: + * - Picker order (`WALLET_ICONS` as-is): icons of the same visual family sit + * next to each other so users can compare close variants. + * - Assignment order (`ICON_ASSIGNMENT_ORDER`): a round-robin across families + * so consecutively created wallets get visually distinct icons. + */ + +export interface WalletIconDef { + /** Stable id stored in the vault; asset file is wallet-icon-.svg */ + id: string; + /** Visual family; icons of one family look alike and are grouped in the picker */ + family: string; +} + +/** All available icon styles, in the order shown in the customization picker. */ +export const WALLET_ICONS: readonly WalletIconDef[] = [ + // Classic petal frames (original set) + { id: 'style-1', family: 'classic' }, // default + { id: 'style-2', family: 'classic' }, + { id: 'style-3', family: 'classic' }, + { id: 'style-8', family: 'classic' }, + { id: 'style-14', family: 'classic' }, + // Gaze directions + { id: 'gaze-left', family: 'gaze' }, + { id: 'gaze-right', family: 'gaze' }, + { id: 'gaze-down', family: 'gaze' }, + // Eye in different frames + { id: 'squircle-eye', family: 'eye' }, + { id: 'seal-eye', family: 'eye' }, + { id: 'quatrefoil-eye', family: 'eye' }, + { id: 'cat-eye', family: 'eye' }, + // Concentric shapes + { id: 'ring', family: 'rings' }, + { id: 'target', family: 'rings' }, + // Celestial + { id: 'sun', family: 'celestial' }, + { id: 'pinwheel-sun', family: 'celestial' }, + { id: 'moon', family: 'celestial' }, + { id: 'north-star', family: 'celestial' }, + { id: 'orbit', family: 'celestial' }, + // Spirals + { id: 'spiral', family: 'spiral' }, + { id: 'fibonacci', family: 'spiral' }, + { id: 'seal-fibonacci', family: 'spiral' }, + // Standalone symbols + { id: 'snowflake', family: 'symbol' }, + { id: 'gem', family: 'symbol' }, + { id: 'heart', family: 'symbol' }, + { id: 'bolt', family: 'symbol' }, + { id: 'keyhole', family: 'symbol' }, + // Nockchain brand + { id: 'nock-n', family: 'brand' }, + { id: 'nock-n-round', family: 'brand' }, +] as const; + +/** + * Account icon colors, in the order shown in the customization picker + * (rainbow starting at the default yellow). + */ +export const ACCOUNT_COLORS = [ + '#FFC413', // yellow (default) + '#EF7A2C', // orange + '#EF2C2F', // red + '#EF2C6A', // raspberry + '#EF2CB1', // pink + '#EF2CE3', // magenta + '#C42CEF', // fuchsia + '#9A2CEF', // violet + '#3C2CEF', // purple + '#2C6AEF', // dark blue + '#2C9AEF', // blue + '#2CC4EF', // sky + '#2CEFE3', // turquoise + '#2CEFB1', // mint + '#2CEF5E', // spring green + '#7AEF2C', // lime + '#96B839', // olive green +] as const; + +export interface WalletStyle { + iconStyleId: string; + iconColor: string; +} + +/** Style of the very first wallet, and fallback whenever a style is missing. */ +export const DEFAULT_WALLET_STYLE: WalletStyle = { + iconStyleId: WALLET_ICONS[0].id, + iconColor: ACCOUNT_COLORS[0], +}; + +/** + * Numeric icon ids written by older versions. Retired styles (4-7, 9-13, 15) + * have no entry and resolve to the default. + */ +const LEGACY_ICON_IDS: Record = { + 1: 'style-1', + 2: 'style-2', + 3: 'style-3', + 8: 'style-8', + 14: 'style-14', +}; + +const KNOWN_ICON_IDS = new Set(WALLET_ICONS.map(icon => icon.id)); + +/** Resolves a stored icon style (string, legacy number, or missing) to a valid id. */ +export function normalizeIconStyleId(value: number | string | undefined | null): string { + if (typeof value === 'string' && KNOWN_ICON_IDS.has(value)) return value; + if (typeof value === 'number' && LEGACY_ICON_IDS[value]) return LEGACY_ICON_IDS[value]; + return DEFAULT_WALLET_STYLE.iconStyleId; +} + +/** + * Icon order used when auto-assigning styles to new wallets: a round-robin + * across families (first icon of each family, then second of each, ...) so + * back-to-back wallets never get two icons from the same family. + */ +export const ICON_ASSIGNMENT_ORDER: readonly string[] = (() => { + const families: string[][] = []; + const byFamily = new Map(); + for (const icon of WALLET_ICONS) { + let bucket = byFamily.get(icon.family); + if (!bucket) { + bucket = []; + byFamily.set(icon.family, bucket); + families.push(bucket); + } + bucket.push(icon.id); + } + const order: string[] = []; + for (let round = 0; order.length < WALLET_ICONS.length; round++) { + for (const bucket of families) { + if (round < bucket.length) order.push(bucket[round]); + } + } + return order; +})(); + +/** + * Color stride for auto-assignment: stepping the rainbow by 7 keeps + * consecutive wallets far apart in hue. 7 is coprime with the palette size, + * so all colors are visited before any repeats. + */ +const COLOR_ASSIGNMENT_STRIDE = 7; + +/** Total number of distinct icon/color combinations. */ +export const TOTAL_STYLE_COMBINATIONS = WALLET_ICONS.length * ACCOUNT_COLORS.length; + +/** + * Deterministic style for the n-th auto-assigned wallet (0-based). + * + * Icons cycle through `ICON_ASSIGNMENT_ORDER` and colors through the strided + * rainbow. Because the icon count (29) and color count (17) are coprime, the + * sequence only repeats a combination after all `TOTAL_STYLE_COMBINATIONS` + * (493) have been used. Index 0 is the default style. + */ +export function getPresetWalletStyle(index: number): WalletStyle { + return { + iconStyleId: ICON_ASSIGNMENT_ORDER[index % ICON_ASSIGNMENT_ORDER.length], + iconColor: ACCOUNT_COLORS[(index * COLOR_ASSIGNMENT_STRIDE) % ACCOUNT_COLORS.length], + }; +} From 04ecefa26792f4bb4bbc89b4b7175dacd4a3395f Mon Sep 17 00:00:00 2001 From: thomasurbit <99678438+thomasurbit@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:21:15 +0200 Subject: [PATCH 09/14] feat: default to sidepanel and harden approvals --- extension/background/index.ts | 478 ++++++++++++++---- extension/content/index.ts | 63 ++- extension/inpage/index.ts | 21 +- extension/popup/hooks/useAutoRejectOnClose.ts | 6 +- extension/popup/screens/DisplayModeScreen.tsx | 4 +- extension/popup/screens/LockTimeScreen.tsx | 8 +- extension/popup/screens/RpcSettingsScreen.tsx | 71 ++- .../popup/screens/ViewSecretPhraseScreen.tsx | 39 +- .../approvals/ConnectApprovalScreen.tsx | 84 ++- .../screens/approvals/SignMessageScreen.tsx | 75 ++- .../screens/approvals/SignRawTxScreen.tsx | 190 ++++--- .../approvals/TransactionApprovalScreen.tsx | 106 +++- .../popup/screens/onboarding/BackupScreen.tsx | 20 +- extension/shared/constants.ts | 23 +- extension/shared/currency.ts | 29 ++ extension/shared/keyfile.ts | 5 +- extension/shared/pending-approvals-session.ts | 14 +- extension/shared/rpc-config.ts | 50 +- extension/shared/side-panel.ts | 8 + extension/shared/types.ts | 22 +- extension/shared/vault.ts | 117 ++++- 21 files changed, 1131 insertions(+), 302 deletions(-) diff --git a/extension/background/index.ts b/extension/background/index.ts index 0c42b95d..4df27b21 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -6,7 +6,7 @@ import { Vault } from '../shared/vault'; import { isNockAddress } from '../shared/validators'; -import { noteToProtobuf, assertNativeRawTx, assertNativeNote } from '../shared/sign-raw-tx-compat'; +import { assertNativeRawTx } from '../shared/sign-raw-tx-compat'; import { isSignTxRequest, isEvmAddress, @@ -17,7 +17,6 @@ import { import type { RpcRequest, RpcResponse, ConnectResponse } from '@nockbox/iris-sdk'; import { ensureWasmInitialized } from '../shared/wasm-utils'; import wasm from '../shared/sdk-wasm.js'; -import type { Note } from '@nockbox/iris-sdk/wasm'; import type { Nicks } from '@nockbox/iris-sdk/wasm'; import type { Digest } from '@nockbox/iris-sdk/wasm'; import { @@ -26,6 +25,7 @@ import { ERROR_CODES, ALARM_NAMES, AUTOLOCK_MINUTES, + AUTOLOCK_ALLOWED_MINUTES, STORAGE_KEYS, SESSION_STORAGE_KEYS, USER_ACTIVITY_METHODS, @@ -33,6 +33,7 @@ import { APPROVAL_CONSTANTS, CHAIN_ID, DISPLAY_MODES, + DEFAULT_DISPLAY_MODE, RUNTIME_MESSAGE_TYPES, } from '../shared/constants'; import type { DisplayMode, ApprovalType } from '../shared/constants'; @@ -45,7 +46,11 @@ import type { SignRawTxRequest, WalletTransaction, } from '../shared/types'; -import { SIDE_PANEL_DEFAULT_PATH, APPROVAL_PROVIDER_METHODS } from '../shared/side-panel'; +import { + SIDE_PANEL_DEFAULT_PATH, + LEGACY_SIGN_RAW_TX_METHOD, + isApprovalProviderMethod, +} from '../shared/side-panel'; import { buildPendingApprovalSessionSnapshot, persistPendingApprovalSession, @@ -69,7 +74,7 @@ let requestQueue: Array<{ * In-memory display mode cache so the user-gesture side panel hook can run * synchronously inside the onMessage listener (any await loses the gesture). */ -let cachedDisplayMode: DisplayMode = DISPLAY_MODES.POPUP; +let cachedDisplayMode: DisplayMode = DEFAULT_DISPLAY_MODE; /** * In-memory cache of approved origins @@ -83,6 +88,13 @@ let approvedOrigins = new Set(); */ const REQUEST_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes +function normalizeAutoLockMinutes(value: unknown): number { + const parsed = typeof value === 'number' ? value : Number(value); + return AUTOLOCK_ALLOWED_MINUTES.includes(parsed as (typeof AUTOLOCK_ALLOWED_MINUTES)[number]) + ? parsed + : AUTOLOCK_MINUTES; +} + /** * RPC connection status * Updated by popup via REPORT_RPC_STATUS when actual gRPC calls succeed/fail @@ -205,8 +217,22 @@ async function ensureSessionRestored(): Promise { */ async function loadApprovedOrigins(): Promise { const stored = await chrome.storage.local.get([STORAGE_KEYS.APPROVED_ORIGINS]); - const origins = stored[STORAGE_KEYS.APPROVED_ORIGINS] || []; - approvedOrigins = new Set(origins); + const origins = Array.isArray(stored[STORAGE_KEYS.APPROVED_ORIGINS]) + ? (stored[STORAGE_KEYS.APPROVED_ORIGINS] as unknown[]) + : []; + const normalizedOrigins = origins + .map(origin => normalizeWebOrigin(origin)) + .filter((origin): origin is string => Boolean(origin)); + + approvedOrigins = new Set(normalizedOrigins); + + // Older versions stored full page URLs. Persist the least-privilege origin form. + if ( + origins.length !== approvedOrigins.size || + origins.some(origin => typeof origin !== 'string' || !approvedOrigins.has(origin)) + ) { + await saveApprovedOrigins(); + } } /** @@ -247,6 +273,30 @@ function isOriginApproved(origin: string): boolean { return approvedOrigins.has(origin); } +function normalizeWebOrigin(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + + if (import.meta.env.DEV && value.startsWith('file://')) { + return 'file://'; + } + + try { + const url = new URL(value); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null; + } + return url.origin; + } catch { + return null; + } +} + +function getSenderOrigin(sender: chrome.runtime.MessageSender): string | null { + return normalizeWebOrigin(sender.origin) ?? normalizeWebOrigin(sender.url); +} + function cancelPendingRequest(requestId: string, code?: number, message?: string): void { const request = pendingRequests.get(requestId); if (!request) { @@ -256,7 +306,7 @@ function cancelPendingRequest(requestId: string, code?: number, message?: string request.sendResponse({ error: { code: code || 4001, message: message || 'Request was cancelled' }, }); - if (currentRequestId == requestId) { + if (currentRequestId === requestId) { currentRequestId = null; currentRequestType = null; } @@ -295,7 +345,9 @@ async function restorePendingApprovalsSession(): Promise { request: entry.request, origin: entry.origin, tabId: entry.tabId, + documentId: entry.documentId, sendResponse: orphanedProviderResponse, + restoredWithoutResponder: true, }); } @@ -304,7 +356,34 @@ async function restorePendingApprovalsSession(): Promise { snapshot.currentRequestId && pendingRequests.has(snapshot.currentRequestId) ? snapshot.currentRequestId : null; - currentRequestType = currentRequestId ? snapshot.currentRequestType : null; + currentRequestType = currentRequestId + ? approvalTypeForRequest(pendingRequests.get(currentRequestId)!.request) + : null; + + // A non-restorable or expired active request may have had valid requests behind it. + // Promote the next queued request so it cannot become unreachable in the side panel. + if (!currentRequestId) { + while (requestQueue.length > 0) { + const next = requestQueue.shift()!; + if (!pendingRequests.has(next.id)) { + continue; + } + currentRequestId = next.id; + currentRequestType = approvalTypeForRequest(pendingRequests.get(next.id)!.request); + break; + } + } + + // Defensive recovery for snapshots written before queue normalization. + if (!currentRequestId && pendingRequests.size > 0) { + const oldest = [...pendingRequests.entries()].sort( + ([, a], [, b]) => a.request.timestamp - b.request.timestamp + )[0]; + if (oldest) { + currentRequestId = oldest[0]; + currentRequestType = approvalTypeForRequest(oldest[1].request); + } + } if ( currentRequestId && @@ -324,6 +403,97 @@ function isRequestExpired(timestamp: number): boolean { return Date.now() - timestamp > REQUEST_EXPIRATION_MS; } +async function isPendingRequesterActive(request: PendingRequest): Promise { + if (request.tabId === undefined) { + return false; + } + + if (!request.documentId) { + // Legacy snapshots lack a document ID. At least require the same tab origin + // and a live Iris content script before allowing a sensitive operation. + try { + const tab = await chrome.tabs.get(request.tabId); + if (normalizeWebOrigin(tab.url) !== request.origin) { + return false; + } + const response = await chrome.tabs.sendMessage(request.tabId, { + type: RUNTIME_MESSAGE_TYPES.REQUESTER_PING, + }); + return response?.ok === true; + } catch { + return false; + } + } + + try { + const response = await chrome.tabs.sendMessage( + request.tabId, + { type: RUNTIME_MESSAGE_TYPES.REQUESTER_PING }, + { documentId: request.documentId } + ); + return response?.ok === true; + } catch { + return false; + } +} + +async function validatePendingApproval( + requestId: string, + pending: PendingRequest, + sendResponse: (response: unknown) => void +): Promise { + if (isRequestExpired(pending.request.timestamp)) { + cancelPendingRequest(requestId, 4003, 'Request expired'); + processNextRequest(); + sendResponse({ error: 'Request expired' }); + return false; + } + + if (pending.restoredWithoutResponder) { + cancelPendingRequest( + requestId, + 4900, + 'The wallet restarted while this request was pending; request the action again' + ); + processNextRequest(); + sendResponse({ + error: 'The wallet restarted while this request was pending; request the action again', + }); + return false; + } + + if (pending.processing) { + sendResponse({ error: 'Request is already being processed' }); + return false; + } + pending.processing = true; + + if (!(await isPendingRequesterActive(pending))) { + cancelPendingRequest(requestId, 4001, 'Requesting page is no longer active'); + processNextRequest(); + sendResponse({ error: 'Requesting page is no longer active' }); + return false; + } + + if (isConnectRequest(pending.request) && !pending.request.accountAddress && !vault.isLocked()) { + pending.request.accountAddress = vault.getAddress(); + void syncPendingApprovalsSession(); + } + + if ( + !('accountAddress' in pending.request) || + typeof pending.request.accountAddress !== 'string' || + vault.getCurrentAccount()?.address !== pending.request.accountAddress + ) { + cancelPendingRequest(requestId, 4001, 'Selected account changed; request the action again'); + processNextRequest(); + sendResponse({ error: 'Selected account changed; request the action again' }); + return false; + } + + return true; +} + /** Parse amount/fee from message boundary. Returns Nicks (string). Throws on invalid input. */ function parseNicksParam( input: unknown, @@ -341,7 +511,9 @@ function parseNicksParam( typeof input === 'number' ? input : typeof input === 'string' - ? parseInt(input.trim(), 10) + ? /^\d+$/.test(input.trim()) + ? Number(input.trim()) + : NaN : NaN; if ( @@ -381,7 +553,7 @@ function isProviderMethod(method: unknown): method is string { method === PROVIDER_METHODS.GET_WALLET_INFO || method === PROVIDER_METHODS.SIGN_TX || // Legacy v0 API method - method === 'nock_signRawTx' + method === LEGACY_SIGN_RAW_TX_METHOD ); } @@ -494,6 +666,11 @@ interface PendingRequest { origin: string; needsUnlock?: boolean; // Flag indicating request is waiting for wallet unlock tabId?: number; + documentId?: string; + /** A service-worker restart lost the original provider callback; never execute this request. */ + restoredWithoutResponder?: boolean; + /** Prevent duplicate approval surfaces from executing the same request concurrently. */ + processing?: boolean; } const pendingRequests = new Map(); @@ -504,7 +681,9 @@ const pendingRequests = new Map(); function isConnectRequest( request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest ): request is ConnectRequest { - return 'timestamp' in request && !('message' in request) && !('to' in request); + return ( + 'timestamp' in request && !('message' in request) && !('to' in request) && !('rawTx' in request) + ); } /** @@ -522,7 +701,15 @@ function isSignRequest( function isSignRawTxRequest( request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest ): request is SignRawTxRequest { - return 'rawTx' in request && 'notes' in request && 'spendConditions' in request; + return ( + 'rawTx' in request && + 'inputs' in request && + 'inputsVerified' in request && + 'inputCount' in request && + 'transactionId' in request && + 'reviewBlockHeight' in request && + 'accountAddress' in request + ); } /** @@ -534,6 +721,15 @@ function isTransactionRequest( return 'to' in request; } +function approvalTypeForRequest( + request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest +): ApprovalType { + if (isSignRawTxRequest(request)) return 'sign-raw-tx'; + if (isTransactionRequest(request)) return 'transaction'; + if (isSignRequest(request)) return 'sign-message'; + return 'connect'; +} + async function getDisplayMode(): Promise { if (!chrome.sidePanel) { return DISPLAY_MODES.POPUP; @@ -541,7 +737,9 @@ async function getDisplayMode(): Promise { const stored = await chrome.storage.local.get([STORAGE_KEYS.DISPLAY_MODE]); const mode = stored[STORAGE_KEYS.DISPLAY_MODE]; - return mode === DISPLAY_MODES.SIDE_PANEL ? DISPLAY_MODES.SIDE_PANEL : DISPLAY_MODES.POPUP; + return mode === DISPLAY_MODES.POPUP || mode === DISPLAY_MODES.SIDE_PANEL + ? mode + : DEFAULT_DISPLAY_MODE; } async function applyDisplayMode(mode: DisplayMode): Promise { @@ -744,19 +942,24 @@ async function createApprovalPopup(requestId: string, type: ApprovalType, tabId? */ function processNextRequest() { if (currentRequestId !== null) { + if (pendingRequests.get(currentRequestId)?.processing) { + return; + } cancelPendingRequest(currentRequestId); } currentRequestType = null; - if (requestQueue.length > 0) { - while (true) { - const next = requestQueue.shift()!; - if (!pendingRequests.has(next.id)) { - continue; - } - createApprovalPopup(next.id, next.type, pendingRequests.get(next.id)?.tabId); - break; + while (requestQueue.length > 0) { + const next = requestQueue.shift()!; + if (!pendingRequests.has(next.id)) { + continue; } + void createApprovalPopup(next.id, next.type, pendingRequests.get(next.id)?.tabId).catch( + error => { + console.error('[Background] Failed to show next approval:', error); + } + ); + break; } void syncPendingApprovalsSession(); @@ -896,10 +1099,27 @@ const initPromise = (async () => { ]); const storedMinutes = stored[STORAGE_KEYS.AUTO_LOCK_MINUTES]; - autoLockMinutes = typeof storedMinutes === 'number' ? storedMinutes : Number(storedMinutes) || 0; + autoLockMinutes = normalizeAutoLockMinutes(storedMinutes); + if (storedMinutes !== autoLockMinutes) { + await chrome.storage.local.set({ + [STORAGE_KEYS.AUTO_LOCK_MINUTES]: autoLockMinutes, + }); + } - // Load persisted lastActivity (survives SW restarts), fallback to now if not set - lastActivity = stored[STORAGE_KEYS.LAST_ACTIVITY] ?? Date.now(); + // Load persisted activity (survives SW restarts). Persist the migration fallback + // immediately so repeated worker restarts cannot keep extending an unlocked session. + const storedLastActivity = stored[STORAGE_KEYS.LAST_ACTIVITY]; + const now = Date.now(); + lastActivity = + typeof storedLastActivity === 'number' && + Number.isFinite(storedLastActivity) && + storedLastActivity > 0 && + storedLastActivity <= now + ? storedLastActivity + : now; + if (storedLastActivity !== lastActivity) { + await chrome.storage.local.set({ [STORAGE_KEYS.LAST_ACTIVITY]: lastActivity }); + } // Load persisted manuallyLocked state manuallyLocked = Boolean(stored[STORAGE_KEYS.MANUALLY_LOCKED]); @@ -911,11 +1131,10 @@ const initPromise = (async () => { await applyDisplayMode(await getDisplayMode()); - // Only schedule alarm if auto-lock is enabled, otherwise ensure any stale alarm is cleared - if (autoLockMinutes > 0) { - scheduleAlarm(); + if (autoLockMinutes === 0) { + await chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); } else { - chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); + scheduleAlarm(); } })(); @@ -972,10 +1191,7 @@ function maybeOpenSidePanelOnGesture(msg: any, sender: chrome.runtime.MessageSen } const method = msg?.payload?.method; - if ( - typeof method !== 'string' || - (!APPROVAL_PROVIDER_METHODS.has(method) && method !== 'nock_signRawTx') - ) { + if (!isApprovalProviderMethod(method)) { return; } @@ -1010,7 +1226,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse(toInternalProviderError(err)); } }; - await touchActivity(payload?.method); + const senderOrigin = getSenderOrigin(_sender); + if (isProviderMethod(payload?.method) && !senderOrigin) { + await sendBridgedResponse({ error: ERROR_CODES.UNAUTHORIZED }); + return; + } // Guard: internal methods (wallet:*) can only be called from popup/extension pages if (payload?.method?.startsWith('wallet:') && !isFromPopup(_sender)) { @@ -1018,16 +1238,24 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } + // Only authorized extension-page methods can extend the unlock window. + // Page-originated provider traffic is not proof of user activity. + await touchActivity(payload?.method); + switch (payload?.method) { // Provider methods (called from injected provider via content script) case PROVIDER_METHODS.CONNECT: - const connectOrigin = _sender.url || _sender.origin || ''; + const connectOrigin = senderOrigin!; // Check if origin is already approved if (!isOriginApproved(connectOrigin) || vault.isLocked()) { // Clear any existing pending unlock requests from same origin to prevent duplicates for (const [existingId, existingData] of pendingRequests.entries()) { - if (existingData.origin === connectOrigin) { + if ( + existingData.origin === connectOrigin && + isConnectRequest(existingData.request) && + !existingData.processing + ) { cancelPendingRequest(existingId); } } @@ -1037,6 +1265,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const connectRequest: ConnectRequest = { id: connectRequestId, origin: connectOrigin, + ...(!vault.isLocked() ? { accountAddress: vault.getAddress() } : {}), timestamp: Date.now(), }; @@ -1048,6 +1277,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }, origin: connectRequest.origin, tabId: _sender.tab?.id, + documentId: _sender.documentId, }); void syncPendingApprovalsSession(); @@ -1074,7 +1304,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case PROVIDER_METHODS.SIGN_MESSAGE: // Validate origin - const signMessageOrigin = _sender.url || _sender.origin || ''; + const signMessageOrigin = senderOrigin!; if (!isOriginApproved(signMessageOrigin)) { await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); return; @@ -1091,10 +1321,23 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { payload.params && typeof payload.params === 'object' ? (payload.params as { message?: unknown }) : undefined; + if ( + typeof signMessageParams?.message !== 'string' || + signMessageParams.message.length > 100_000 + ) { + await sendBridgedResponse({ + error: { + code: -32602, + message: 'Message must be a string of at most 100,000 characters', + }, + }); + return; + } const signRequest: SignRequest = { id: newSignRequestId, origin: signMessageOrigin, - message: typeof signMessageParams?.message === 'string' ? signMessageParams.message : '', + message: signMessageParams.message, + accountAddress: vault.getAddress(), timestamp: Date.now(), }; @@ -1106,6 +1349,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }, origin: signRequest.origin, tabId: _sender.tab?.id, + documentId: _sender.documentId, }); void syncPendingApprovalsSession(); @@ -1118,7 +1362,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case PROVIDER_METHODS.SIGN_TX: // Validate origin - const signRawTxOrigin = _sender.url || _sender.origin || ''; + const signRawTxOrigin = senderOrigin!; if (!isOriginApproved(signRawTxOrigin)) { await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); return; @@ -1135,24 +1379,40 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { await sendBridgedResponse({ error: { code: -32602, message: 'Invalid params' } }); return; } - await ensureWasmInitialized(); - const nativeRawTx = wasm.nockchainTxToRawTx(signTxParams.tx); - const nativeNotes = (signTxParams.notes ?? []) as Note[]; - const nativeSpendConditions = wasm.rawTxInputSpendConditions(nativeRawTx); - assertNativeRawTx(nativeRawTx); - - const outputs = await vault.computeOutputs(nativeRawTx); + let nativeRawTx: unknown; + let review: Awaited>; + try { + await ensureWasmInitialized(); + nativeRawTx = wasm.nockchainTxToRawTx(signTxParams.tx); + assertNativeRawTx(nativeRawTx); + review = await vault.describeRawTxForApproval(nativeRawTx); + } catch (error) { + console.warn('[Background] Rejected unverifiable raw transaction:', error); + await sendBridgedResponse({ + error: { + code: -32602, + message: + error instanceof Error ? error.message : 'Transaction inputs could not be verified', + }, + }); + return; + } const signRawTxId = crypto.randomUUID(); const signRawTxRequest: SignRawTxRequest = { id: signRawTxId, origin: signRawTxOrigin, rawTx: nativeRawTx, - notes: nativeNotes, - spendConditions: nativeSpendConditions, - outputs: outputs, + inputs: review.inputs, + inputsVerified: review.inputsVerified, + inputCount: review.inputCount, + outputs: review.outputs, + transactionId: review.transactionId, + totalFee: review.totalFee, + reviewBlockHeight: review.blockHeight, + accountAddress: review.accountAddress, timestamp: Date.now(), - } as SignRawTxRequest; + }; // Store pending request with response callback pendingRequests.set(signRawTxId, { @@ -1162,6 +1422,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }, origin: signRawTxRequest.origin, tabId: _sender.tab?.id, + documentId: _sender.documentId, }); void syncPendingApprovalsSession(); @@ -1174,7 +1435,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case PROVIDER_METHODS.SEND_TRANSACTION: // Validate origin - const sendTxOrigin = _sender.url || _sender.origin || ''; + const sendTxOrigin = senderOrigin!; if (!isOriginApproved(sendTxOrigin)) { await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); return; @@ -1196,6 +1457,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { try { amountNicks = parseNicksParam(amount, 'amount'); feeNicks = parseNicksParam(fee, 'fee', { allowZero: true }); + if (Number(amountNicks) + Number(feeNicks) > Number.MAX_SAFE_INTEGER) { + throw new Error('Amount plus fee exceeds the supported range'); + } } catch (err) { await sendBridgedResponse(toInvalidParamsError(err)); return; @@ -1209,6 +1473,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { to, amount: amountNicks, fee: feeNicks, + accountAddress: vault.getAddress(), timestamp: Date.now(), }; @@ -1220,6 +1485,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }, origin: txRequest.origin, tabId: _sender.tab?.id, + documentId: _sender.documentId, }); void syncPendingApprovalsSession(); @@ -1232,7 +1498,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case PROVIDER_METHODS.GET_WALLET_INFO: // Validate origin - const getInfoOrigin = _sender.url || _sender.origin || ''; + const getInfoOrigin = senderOrigin!; if (!isOriginApproved(getInfoOrigin)) { await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); return; @@ -1255,20 +1521,22 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Internal methods (called from popup) case INTERNAL_METHODS.SET_AUTO_LOCK: const newMinutes = payload.params?.[0]; - autoLockMinutes = typeof newMinutes === 'number' ? newMinutes : Number(newMinutes) || 0; + const normalizedMinutes = normalizeAutoLockMinutes(newMinutes); + if (Number(newMinutes) !== normalizedMinutes) { + sendResponse({ error: ERROR_CODES.INVALID_PARAMS }); + return; + } + autoLockMinutes = normalizedMinutes; await chrome.storage.local.set({ [STORAGE_KEYS.AUTO_LOCK_MINUTES]: autoLockMinutes, }); - // Start or stop alarm based on setting - if (autoLockMinutes > 0) { - // Reset activity timestamp when enabling auto-lock - lastActivity = Date.now(); - await chrome.storage.local.set({ [STORAGE_KEYS.LAST_ACTIVITY]: lastActivity }); - scheduleAlarm(); + lastActivity = Date.now(); + await chrome.storage.local.set({ [STORAGE_KEYS.LAST_ACTIVITY]: lastActivity }); + if (autoLockMinutes === 0) { + await chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); } else { - // Clear alarm when disabling auto-lock - chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); + scheduleAlarm(); } sendResponse({ ok: true }); return; @@ -1750,14 +2018,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const getPendingSignRawTxId = payload.params?.[0]; const signRawTxPending = pendingRequests.get(getPendingSignRawTxId); if (signRawTxPending && isSignRawTxRequest(signRawTxPending.request)) { - const req = signRawTxPending.request; - // Convert native notes to protobuf for popup display (NoteItem expects protobuf) - req.notes.forEach(assertNativeNote); - const forPopup = { - ...req, - notes: req.notes.map(n => noteToProtobuf(n as Note)), - }; - sendResponse(forPopup); + sendResponse(signRawTxPending.request); } else { sendResponse({ error: ERROR_CODES.NOT_FOUND }); } @@ -1769,10 +2030,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (approveTxPending && isTransactionRequest(approveTxPending.request)) { const txRequest = approveTxPending.request; - // Check if request has expired (replay prevention) - if (isRequestExpired(txRequest.timestamp)) { - cancelPendingRequest(approveTxId, 4003, 'Request expired'); - sendResponse({ error: 'Request expired' }); + if (!(await validatePendingApproval(approveTxId, approveTxPending, sendResponse))) { return; } @@ -1783,7 +2041,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { txRequest.fee, false, undefined, - 'provider_send' + 'provider_send', + txRequest.accountAddress ); if ('error' in v2Result) { @@ -1821,6 +2080,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const rejectTxId = payload.params?.[0]; const rejectTxPending = pendingRequests.get(rejectTxId); if (rejectTxPending) { + if (rejectTxPending.processing) { + sendResponse({ error: 'Request is already being processed' }); + return; + } cancelPendingRequest(rejectTxId, 4001, 'User rejected the transaction'); processNextRequest(); sendResponse({ success: true }); @@ -1835,15 +2098,15 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (approveSignPending && isSignRequest(approveSignPending.request)) { const signRequest = approveSignPending.request; - // Check if request has expired (replay prevention) - if (isRequestExpired(signRequest.timestamp)) { - cancelPendingRequest(approveSignId, 4003, 'Request expired'); - sendResponse({ error: 'Request expired' }); + if (!(await validatePendingApproval(approveSignId, approveSignPending, sendResponse))) { return; } try { - const signMessageResponse = await vault.signMessage([signRequest.message]); + const signMessageResponse = await vault.signMessage( + [signRequest.message], + signRequest.accountAddress + ); approveSignPending.sendResponse(signMessageResponse); cancelPendingRequest(approveSignId); processNextRequest(); @@ -1863,6 +2126,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const rejectSignId = payload.params?.[0]; const rejectSignPending = pendingRequests.get(rejectSignId); if (rejectSignPending) { + if (rejectSignPending.processing) { + sendResponse({ error: 'Request is already being processed' }); + return; + } cancelPendingRequest(rejectSignId, 4001, 'User rejected the signature request'); processNextRequest(); sendResponse({ success: true }); @@ -1878,18 +2145,39 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (approveSignRawTxPending && isSignRawTxRequest(approveSignRawTxPending.request)) { const signRawTxRequest = approveSignRawTxPending.request; - // Check if request has expired (replay prevention) - if (isRequestExpired(signRawTxRequest.timestamp)) { - cancelPendingRequest(approveSignRawTxId, 4003, 'Request expired'); - sendResponse({ error: 'Request expired' }); + if ( + !(await validatePendingApproval( + approveSignRawTxId, + approveSignRawTxPending, + sendResponse + )) + ) { return; } try { assertNativeRawTx(signRawTxRequest.rawTx); + const currentReview = await vault.describeRawTxForApproval(signRawTxRequest.rawTx); + if ( + currentReview.transactionId !== signRawTxRequest.transactionId || + currentReview.blockHeight !== signRawTxRequest.reviewBlockHeight || + currentReview.accountAddress !== signRawTxRequest.accountAddress || + (signRawTxRequest.inputsVerified && !currentReview.inputsVerified) + ) { + throw new Error('Transaction review changed after approval was requested'); + } + const signedTx = await vault.signRawTx({ rawTx: signRawTxRequest.rawTx, + blockHeight: signRawTxRequest.reviewBlockHeight, + accountAddress: signRawTxRequest.accountAddress, }); + const signedRawTx = wasm.nockchainTxToRawTx(signedTx); + const signedTransactionId = String(wasm.rawTxId(signedRawTx)); + if (signedTransactionId !== signRawTxRequest.transactionId) { + throw new Error('Signed transaction does not match the approved transaction'); + } + approveSignRawTxPending.sendResponse({ tx: signedTx }); cancelPendingRequest(approveSignRawTxId); processNextRequest(); @@ -1911,6 +2199,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const rejectSignRawTxId = payload.params?.[0]; const rejectSignRawTxPending = pendingRequests.get(rejectSignRawTxId); if (rejectSignRawTxPending) { + if (rejectSignRawTxPending.processing) { + sendResponse({ error: 'Request is already being processed' }); + return; + } cancelPendingRequest(rejectSignRawTxId, 4001, 'User rejected the signature request'); processNextRequest(); sendResponse({ success: true }); @@ -1923,6 +2215,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const getPendingConnectId = payload.params?.[0]; const connectPending = pendingRequests.get(getPendingConnectId); if (connectPending && isConnectRequest(connectPending.request)) { + if (!connectPending.request.accountAddress && !vault.isLocked()) { + connectPending.request.accountAddress = vault.getAddress(); + void syncPendingApprovalsSession(); + } sendResponse(connectPending.request); } else { sendResponse({ error: ERROR_CODES.NOT_FOUND }); @@ -1935,16 +2231,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (approveConnectPending && isConnectRequest(approveConnectPending.request)) { const connectRequest = approveConnectPending.request; - // Check if request has expired (replay prevention) - if (isRequestExpired(connectRequest.timestamp)) { - cancelPendingRequest(approveConnectId, 4003, 'Request expired'); - sendResponse({ error: 'Request expired' }); + if ( + !(await validatePendingApproval(approveConnectId, approveConnectPending, sendResponse)) + ) { return; } try { const approveRpcConfig = await getEffectiveRpcConfig(); - const connectResponse = buildConnectResponse(vault.getAddress(), approveRpcConfig); + const connectResponse = buildConnectResponse( + connectRequest.accountAddress!, + approveRpcConfig + ); // Add origin only after the response can be built. await approveOrigin(connectRequest.origin); @@ -1972,6 +2270,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const rejectConnectId = payload.params?.[0]; const rejectConnectPending = pendingRequests.get(rejectConnectId); if (rejectConnectPending) { + if (rejectConnectPending.processing) { + sendResponse({ error: 'Request is already being processed' }); + return; + } cancelPendingRequest(rejectConnectId, 4001, 'User rejected the connection'); processNextRequest(); sendResponse({ success: true }); @@ -2352,7 +2654,7 @@ chrome.alarms.onAlarm.addListener(async alarm => { await initPromise; await ensureSessionRestored(); - // Don't auto-lock if set to "never" (0 minutes) - stop the alarm cycle + // Zero is the explicit "Never" preference. if (autoLockMinutes <= 0) { chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); return; diff --git a/extension/content/index.ts b/extension/content/index.ts index 0124ec6c..da0879e3 100644 --- a/extension/content/index.ts +++ b/extension/content/index.ts @@ -5,7 +5,37 @@ * Note: The inpage provider is injected separately via manifest.json with "world": "MAIN" */ -import { MESSAGE_TARGETS } from '../shared/constants'; +import { MESSAGE_TARGETS, RUNTIME_MESSAGE_TYPES } from '../shared/constants'; + +type BridgeRequest = { + target: string; + id: string; + payload: { + method: string; + params?: unknown; + api?: unknown; + timeout?: unknown; + }; +}; + +function isBridgeRequest(data: unknown): data is BridgeRequest { + if (!data || typeof data !== 'object') { + return false; + } + + const request = data as Record; + const payload = request.payload; + return ( + request.target === MESSAGE_TARGETS.WALLET_BRIDGE && + typeof request.id === 'string' && + request.id.length > 0 && + request.id.length <= 128 && + !Object.prototype.hasOwnProperty.call(request, 'reply') && + Boolean(payload) && + typeof payload === 'object' && + typeof (payload as Record).method === 'string' + ); +} /** * Bridge page <-> Service Worker @@ -15,25 +45,29 @@ window.addEventListener('message', async (evt: MessageEvent) => { const data = evt.data; // Filter messages: must be for us and from the page - if (!data || data.target !== MESSAGE_TARGETS.WALLET_BRIDGE || evt.source !== window) { + if (evt.source !== window || !isBridgeRequest(data)) { return; } - // Only forward request messages (with payload), not reply messages - if (!data.payload || data.reply !== undefined) { - return; + let reply: unknown; + try { + reply = await chrome.runtime.sendMessage(data); + } catch (error) { + reply = { + error: { + code: 4900, + message: error instanceof Error ? error.message : 'Wallet extension is unavailable', + }, + }; } - // Forward to service worker and relay response back to page - const reply = await chrome.runtime.sendMessage(data); - const responseMessage = { target: MESSAGE_TARGETS.WALLET_BRIDGE, id: data.id, reply, }; - window.postMessage(responseMessage, '*'); + window.postMessage(responseMessage, window.location.origin); }); /** @@ -41,19 +75,24 @@ window.addEventListener('message', async (evt: MessageEvent) => { * These are emitted when wallet state changes (account switch, lock, etc.) */ chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => { + if (message.type === RUNTIME_MESSAGE_TYPES.REQUESTER_PING) { + _sendResponse({ ok: true }); + return; + } + // Only handle wallet events if (message.type !== 'WALLET_EVENT') { return; } - // Relay to page with __iris brand for security - // This prevents malicious scripts from forging wallet events + // Relay advisory state notifications. Page scripts can forge same-window + // events, so dApps must re-query authoritative state through the provider. window.postMessage( { __iris: true, type: `nockchain_${message.eventType}`, data: message.data, }, - '*' + window.location.origin ); }); diff --git a/extension/inpage/index.ts b/extension/inpage/index.ts index 0316a9a4..b99d33e1 100644 --- a/extension/inpage/index.ts +++ b/extension/inpage/index.ts @@ -17,7 +17,7 @@ class NockProvider implements InjectedNockchain { * @param args - Request arguments with method and params */ request(args: RpcRequest): Promise { - const id = Math.random().toString(36).slice(2); + const id = crypto.randomUUID(); // Post message to content script window.postMessage( @@ -26,7 +26,7 @@ class NockProvider implements InjectedNockchain { id, payload: args, }, - '*' + window.location.origin ); // Wait for response with timeout @@ -37,14 +37,27 @@ class NockProvider implements InjectedNockchain { const data = evt.data; // Check if this is our response (must have a reply field, not just the request) - if (data?.target === MESSAGE_TARGET && data.id === id && data.reply !== undefined) { + if ( + evt.source === window && + data && + typeof data === 'object' && + data.target === MESSAGE_TARGET && + data.id === id && + Object.prototype.hasOwnProperty.call(data, 'reply') + ) { window.removeEventListener('message', handler); if (timeoutId) { clearTimeout(timeoutId); } if (data.reply?.error) { - reject(new Error(data.reply.error)); + const message = + typeof data.reply.error === 'string' + ? data.reply.error + : typeof data.reply.error?.message === 'string' + ? data.reply.error.message + : 'Wallet request failed'; + reject(new Error(message)); } else { resolve(data.reply); } diff --git a/extension/popup/hooks/useAutoRejectOnClose.ts b/extension/popup/hooks/useAutoRejectOnClose.ts index 88ef182f..92eb75b0 100644 --- a/extension/popup/hooks/useAutoRejectOnClose.ts +++ b/extension/popup/hooks/useAutoRejectOnClose.ts @@ -11,8 +11,12 @@ import { useEffect } from 'react'; import { send } from '../utils/messaging'; -export function useAutoRejectOnClose(requestId: string, rejectMethod: string) { +export function useAutoRejectOnClose(requestId: string | null, rejectMethod: string) { useEffect(() => { + if (!requestId) { + return; + } + const handleBeforeUnload = () => { // Reject the request when window is closing send(rejectMethod, [requestId]).catch(console.error); diff --git a/extension/popup/screens/DisplayModeScreen.tsx b/extension/popup/screens/DisplayModeScreen.tsx index 264d5c50..adbe78b7 100644 --- a/extension/popup/screens/DisplayModeScreen.tsx +++ b/extension/popup/screens/DisplayModeScreen.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useStore } from '../store'; import { send } from '../utils/messaging'; -import { INTERNAL_METHODS, DISPLAY_MODES } from '../../shared/constants'; +import { INTERNAL_METHODS, DISPLAY_MODES, DEFAULT_DISPLAY_MODE } from '../../shared/constants'; import type { DisplayMode } from '../../shared/constants'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { isSidePanel, isSidePanelSupported } from '../utils/displayContext'; @@ -9,7 +9,7 @@ import { SIDE_PANEL_DEFAULT_PATH } from '../../shared/side-panel'; export function DisplayModeScreen() { const { navigate } = useStore(); - const [mode, setMode] = useState(DISPLAY_MODES.POPUP); + const [mode, setMode] = useState(DEFAULT_DISPLAY_MODE); const [isSaving, setIsSaving] = useState(false); const [showPopupHint, setShowPopupHint] = useState(false); diff --git a/extension/popup/screens/LockTimeScreen.tsx b/extension/popup/screens/LockTimeScreen.tsx index c8d937a9..9e0ba7e0 100644 --- a/extension/popup/screens/LockTimeScreen.tsx +++ b/extension/popup/screens/LockTimeScreen.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useStore } from '../store'; import { send } from '../utils/messaging'; -import { INTERNAL_METHODS } from '../../shared/constants'; +import { AUTOLOCK_MINUTES, INTERNAL_METHODS } from '../../shared/constants'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; type LockTimeOption = '1min' | '5min' | '10min' | '15min' | '30min' | '1hour' | '4hours' | 'never'; @@ -16,7 +16,7 @@ function minutesToOption(minutes: number): LockTimeOption { if (minutes === 60) return '1hour'; if (minutes === 240) return '4hours'; if (minutes === 0) return 'never'; - return '15min'; // default + return '10min'; } function optionToMinutes(option: LockTimeOption): number { @@ -38,7 +38,7 @@ function optionToMinutes(option: LockTimeOption): number { case 'never': return 0; default: - return 15; + return AUTOLOCK_MINUTES; } } @@ -48,7 +48,7 @@ function optionToMinutes(option: LockTimeOption): number { */ export function LockTimeScreen() { const { navigate } = useStore(); - const [selectedTime, setSelectedTime] = useState('15min'); + const [selectedTime, setSelectedTime] = useState('10min'); // Load current lock time setting on mount useEffect(() => { diff --git a/extension/popup/screens/RpcSettingsScreen.tsx b/extension/popup/screens/RpcSettingsScreen.tsx index 9c06c70e..d2813a73 100644 --- a/extension/popup/screens/RpcSettingsScreen.tsx +++ b/extension/popup/screens/RpcSettingsScreen.tsx @@ -12,11 +12,20 @@ import { getEffectiveRpcConfig, saveRpcConfig, clearRpcConfig, + normalizeRpcUrl, BLOCK_EXPLORER_OPTIONS, NOCKSCAN_URL, NOCKBLOCKS_URL, } from '../../shared/rpc-config'; +function isCustomRpcUrl(value: string): boolean { + try { + return normalizeRpcUrl(value) !== normalizeRpcUrl(defaultRpcConfig.rpcUrl); + } catch { + return false; + } +} + function txEngineHeightsToJson(heights: TxEngineActivationHeights | undefined): string { if (!heights || Object.keys(heights).length === 0) { return '{\n "0": { "tx_engine_version": 1, "tx_engine_patch": 0, "min_fee": "0", "cost_per_word": "32768", "witness_word_div": 1 }\n}'; @@ -74,9 +83,10 @@ export function RpcSettingsScreen() { ); const [explorerOpen, setExplorerOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false); - const [isLoading, setIsLoading] = useState(true); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [txEngineError, setTxEngineError] = useState(null); + const [rpcUrlError, setRpcUrlError] = useState(null); + const [customRpcAcknowledged, setCustomRpcAcknowledged] = useState(false); const explorerRef = useRef(null); useClickOutside(explorerRef, () => setExplorerOpen(false), explorerOpen); @@ -99,7 +109,6 @@ export function RpcSettingsScreen() { DEFAULT_COINBASE_TIMELOCK_BLOCKS ) ); - setIsLoading(false); }); }, []); @@ -108,6 +117,19 @@ export function RpcSettingsScreen() { } async function handleSave() { + let normalizedRpcUrl: string; + try { + normalizedRpcUrl = normalizeRpcUrl(rpcUrl); + } catch (error) { + setRpcUrlError(error instanceof Error ? error.message : 'Enter a valid RPC URL'); + return; + } + if (isCustomRpcUrl(normalizedRpcUrl) && !customRpcAcknowledged) { + setRpcUrlError('Confirm that you trust this custom RPC before saving'); + return; + } + setRpcUrlError(null); + const parsed = parseTxEngineHeightsJson(txEngineConfig); if (!parsed) { setTxEngineError( @@ -127,7 +149,7 @@ export function RpcSettingsScreen() { try { await saveRpcConfig({ networkName, - rpcUrl: rpcUrl.trim(), + rpcUrl: normalizedRpcUrl, blockExplorerUrl, txEngineActivationHeights: parsed, coinbaseTimelockBlocks: timelock, @@ -149,7 +171,10 @@ export function RpcSettingsScreen() { String(defaultRpcConfig.coinbaseTimelockBlocks ?? DEFAULT_COINBASE_TIMELOCK_BLOCKS) ); setTxEngineError(null); + setRpcUrlError(null); + setCustomRpcAcknowledged(false); await clearRpcConfig(); + await refreshRpcDisplayConfig(); } const inputClass = @@ -212,10 +237,46 @@ export function RpcSettingsScreen() { className={inputClass} style={inputStyle} value={rpcUrl} - onChange={e => setRpcUrl(e.target.value)} + onChange={e => { + setRpcUrl(e.target.value); + setRpcUrlError(null); + setCustomRpcAcknowledged(false); + }} onFocus={e => (e.currentTarget.style.borderColor = 'var(--color-primary)')} - onBlur={e => (e.currentTarget.style.borderColor = 'var(--color-surface-700)')} + onBlur={e => + (e.currentTarget.style.borderColor = rpcUrlError + ? 'var(--color-error, #ef4444)' + : 'var(--color-surface-700)') + } /> + {rpcUrlError && ( + + {rpcUrlError} + + )} + {isCustomRpcUrl(rpcUrl) && ( + + )}
diff --git a/extension/popup/screens/ViewSecretPhraseScreen.tsx b/extension/popup/screens/ViewSecretPhraseScreen.tsx index 9a534f82..dc60d31b 100644 --- a/extension/popup/screens/ViewSecretPhraseScreen.tsx +++ b/extension/popup/screens/ViewSecretPhraseScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useStore } from '../store'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { CheckIcon } from '../components/icons/CheckIcon'; @@ -20,6 +20,13 @@ export function ViewSecretPhraseScreen() { // Get secret phrase from temporary store (set by KeySettingsPasswordScreen) const secretPhrase = onboardingMnemonic ? onboardingMnemonic.split(' ') : []; + useEffect( + () => () => { + setOnboardingMnemonic(null); + }, + [setOnboardingMnemonic] + ); + function handleBack() { // Clear mnemonic from memory when leaving screen setOnboardingMnemonic(null); @@ -31,14 +38,16 @@ export function ViewSecretPhraseScreen() { } async function handleCopySecretPhrase() { - if (onboardingMnemonic) { - try { - await navigator.clipboard.writeText(onboardingMnemonic); - setCopiedSeed(true); - setTimeout(() => setCopiedSeed(false), 2000); - } catch (err) { - console.error('Failed to copy secret phrase:', err); - } + if (!onboardingMnemonic) { + return; + } + + try { + await navigator.clipboard.writeText(onboardingMnemonic); + setCopiedSeed(true); + setTimeout(() => setCopiedSeed(false), 2000); + } catch (error) { + console.error('Failed to copy secret phrase:', error); } } @@ -55,9 +64,9 @@ export function ViewSecretPhraseScreen() { const keyfile = exportKeyfile(onboardingMnemonic); const timestamp = new Date().toISOString().split('T')[0]; downloadKeyfile(keyfile, `nockchain-keyfile-${timestamp}.json`); - } catch (err) { + } catch (downloadError) { setError('Failed to export keyfile'); - console.error(err); + console.error(downloadError); } finally { setIsExporting(false); } @@ -74,8 +83,10 @@ export function ViewSecretPhraseScreen() { style={{ backgroundColor: 'var(--color-bg)' }} > @@ -108,6 +119,7 @@ export function ViewSecretPhraseScreen() { {/* Download Keyfile Link */}
diff --git a/extension/popup/screens/approvals/ConnectApprovalScreen.tsx b/extension/popup/screens/approvals/ConnectApprovalScreen.tsx index 999f54b6..bcdc027e 100644 --- a/extension/popup/screens/approvals/ConnectApprovalScreen.tsx +++ b/extension/popup/screens/approvals/ConnectApprovalScreen.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useStore } from '../../store'; import { AccountIcon } from '../../components/AccountIcon'; import { SiteIcon } from '../../components/SiteIcon'; @@ -9,16 +10,50 @@ import { closeAfterApproval } from '../../utils/displayContext'; export function ConnectApprovalScreen() { const { navigate, pendingConnectRequest, setPendingConnectRequest, wallet } = useStore(); + const [isConnecting, setIsConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(''); + + useAutoRejectOnClose(pendingConnectRequest?.id ?? null, INTERNAL_METHODS.REJECT_CONNECTION); + + useEffect(() => { + if (!pendingConnectRequest) { + navigate('home'); + } + }, [navigate, pendingConnectRequest]); if (!pendingConnectRequest) { - navigate('home'); return null; } - const { id, origin } = pendingConnectRequest; + const { id, origin, accountAddress } = pendingConnectRequest; const domain = origin.includes('://') ? new URL(origin).hostname : origin; + const connectingAccount = accountAddress + ? wallet.accounts.find(account => account.address === accountAddress) + : wallet.currentAccount; + const connectingAddress = accountAddress ?? connectingAccount?.address; - useAutoRejectOnClose(id, INTERNAL_METHODS.REJECT_CONNECTION); + async function handleConnect() { + setIsConnecting(true); + setConnectionError(''); + try { + const result = await send<{ success?: boolean; error?: string }>( + INTERNAL_METHODS.APPROVE_CONNECTION, + [id] + ); + if (result?.error || !result?.success) { + setConnectionError(result?.error || 'Connection could not be approved'); + return; + } + setPendingConnectRequest(null); + closeAfterApproval(navigate); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : 'Connection could not be approved' + ); + } finally { + setIsConnecting(false); + } + } async function handleReject() { await send(INTERNAL_METHODS.REJECT_CONNECTION, [id]); @@ -26,12 +61,6 @@ export function ConnectApprovalScreen() { closeAfterApproval(navigate); } - async function handleConnect() { - await send(INTERNAL_METHODS.APPROVE_CONNECTION, [id]); - setPendingConnectRequest(null); - closeAfterApproval(navigate); - } - const bg = 'var(--color-bg)'; const surface = 'var(--color-surface-800)'; const textPrimary = 'var(--color-text-primary)'; @@ -92,20 +121,33 @@ export function ConnectApprovalScreen() { style={{ backgroundColor: surface }} >

- {wallet.currentAccount?.name || 'Unknown'} + {connectingAccount?.name || 'Unknown'}

- {truncateAddress(wallet.currentAccount?.address)} + {truncateAddress(connectingAddress)}

+ + {connectionError && ( +
+ {connectionError} +
+ )}
@@ -114,11 +156,21 @@ export function ConnectApprovalScreen() { className="px-4 py-2.5 shrink-0 flex gap-3" style={{ borderTop: `1px solid ${divider}` }} > - -

diff --git a/extension/popup/screens/approvals/SignMessageScreen.tsx b/extension/popup/screens/approvals/SignMessageScreen.tsx index 92e69b2f..c166c2d9 100644 --- a/extension/popup/screens/approvals/SignMessageScreen.tsx +++ b/extension/popup/screens/approvals/SignMessageScreen.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useStore } from '../../store'; import { AccountIcon } from '../../components/AccountIcon'; import { SiteIcon } from '../../components/SiteIcon'; @@ -9,15 +10,23 @@ import { closeAfterApproval } from '../../utils/displayContext'; export function SignMessageScreen() { const { navigate, pendingSignRequest, setPendingSignRequest, wallet } = useStore(); + const [isSigning, setIsSigning] = useState(false); + const [signError, setSignError] = useState(''); + + useAutoRejectOnClose(pendingSignRequest?.id ?? null, INTERNAL_METHODS.REJECT_SIGN_MESSAGE); + + useEffect(() => { + if (!pendingSignRequest) { + navigate('home'); + } + }, [navigate, pendingSignRequest]); if (!pendingSignRequest) { - navigate('home'); return null; } - const { id, origin, message } = pendingSignRequest; - - useAutoRejectOnClose(id, INTERNAL_METHODS.REJECT_SIGN_MESSAGE); + const { id, origin, message, accountAddress } = pendingSignRequest; + const signingAccount = wallet.accounts.find(account => account.address === accountAddress); async function handleDecline() { await send(INTERNAL_METHODS.REJECT_SIGN_MESSAGE, [id]); @@ -26,9 +35,24 @@ export function SignMessageScreen() { } async function handleSign() { - await send(INTERNAL_METHODS.APPROVE_SIGN_MESSAGE, [id]); - setPendingSignRequest(null); - closeAfterApproval(navigate); + setIsSigning(true); + setSignError(''); + try { + const result = await send<{ success?: boolean; error?: string }>( + INTERNAL_METHODS.APPROVE_SIGN_MESSAGE, + [id] + ); + if (result?.error || !result?.success) { + setSignError(result?.error || 'Message could not be signed'); + return; + } + setPendingSignRequest(null); + closeAfterApproval(navigate); + } catch (error) { + setSignError(error instanceof Error ? error.message : 'Message could not be signed'); + } finally { + setIsSigning(false); + } } const bg = 'var(--color-bg)'; @@ -103,20 +127,33 @@ export function SignMessageScreen() { style={{ backgroundColor: surface }} >

- {wallet.currentAccount?.name || 'Unknown'} + {signingAccount?.name || 'Unknown'}

- {truncateAddress(wallet.currentAccount?.address)} + {truncateAddress(accountAddress)}

+ + {signError && ( +
+ {signError} +
+ )}

@@ -125,11 +162,21 @@ export function SignMessageScreen() { className="px-4 py-2.5 shrink-0 flex gap-3" style={{ borderTop: `1px solid ${divider}` }} > - -
diff --git a/extension/popup/screens/approvals/SignRawTxScreen.tsx b/extension/popup/screens/approvals/SignRawTxScreen.tsx index de18945e..bd6bc715 100644 --- a/extension/popup/screens/approvals/SignRawTxScreen.tsx +++ b/extension/popup/screens/approvals/SignRawTxScreen.tsx @@ -8,7 +8,7 @@ import { closeAfterApproval } from '../../utils/displayContext'; import { AccountIcon } from '../../components/AccountIcon'; import { SiteIcon } from '../../components/SiteIcon'; import { truncateAddress } from '../../utils/format'; -import { nickToNock, formatNock } from '../../../shared/currency'; +import { formatNicksAsNock } from '../../../shared/currency'; interface NoteItemProps { note: any; @@ -43,9 +43,7 @@ function NoteItem({ note, type, textPrimary, textMuted, surface }: NoteItemProps } const assetsValue = versionData.assets?.value || '0'; - const nicks = parseInt(assetsValue, 10); - const nocks = nickToNock(nicks); - const formattedNocks = formatNock(nocks); + const formattedNocks = formatNicksAsNock(String(assetsValue)); const firstName = versionData.name?.first || ''; const lastName = versionData.name?.last || ''; @@ -54,10 +52,14 @@ function NoteItem({ note, type, textPrimary, textMuted, surface }: NoteItemProps // Truncate name: first 4 chars of first name ... last 4 chars of last name const truncatedName = `[ ${firstName.slice(0, 4)}...${lastName.slice(-4)} ]`; - const handleCopy = () => { - navigator.clipboard.writeText(fullName); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(fullName); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy note name:', error); + } }; return ( @@ -65,11 +67,13 @@ function NoteItem({ note, type, textPrimary, textMuted, surface }: NoteItemProps
{formattedNocks} NOCK {type} - void handleCopy()} title={fullName} + aria-label={`Copy note name ${fullName}`} > {truncatedName} {copied && ( @@ -77,7 +81,7 @@ function NoteItem({ note, type, textPrimary, textMuted, surface }: NoteItemProps Copied! )} - +

); @@ -85,15 +89,33 @@ function NoteItem({ note, type, textPrimary, textMuted, surface }: NoteItemProps export function SignRawTxScreen() { const { pendingSignRawTxRequest, setPendingSignRawTxRequest, navigate, wallet } = useStore(); + const [isSigning, setIsSigning] = useState(false); + const [signError, setSignError] = useState(''); + + useAutoRejectOnClose(pendingSignRawTxRequest?.id ?? null, INTERNAL_METHODS.REJECT_SIGN_RAW_TX); + + useEffect(() => { + if (!pendingSignRawTxRequest) { + navigate('home'); + } + }, [navigate, pendingSignRawTxRequest]); if (!pendingSignRawTxRequest) { - navigate('home'); return null; } - const { id, origin, rawTx, notes, spendConditions, outputs } = pendingSignRawTxRequest; - - useAutoRejectOnClose(id, INTERNAL_METHODS.REJECT_SIGN_RAW_TX); + const { + id, + origin, + inputs, + inputsVerified, + inputCount, + outputs, + transactionId, + totalFee, + accountAddress, + } = pendingSignRawTxRequest; + const signingAccount = wallet.accounts.find(account => account.address === accountAddress); async function handleDecline() { await send(INTERNAL_METHODS.REJECT_SIGN_RAW_TX, [id]); @@ -102,33 +124,27 @@ export function SignRawTxScreen() { } async function handleSign() { - await send(INTERNAL_METHODS.APPROVE_SIGN_RAW_TX, [id]); - setPendingSignRawTxRequest(null); - closeAfterApproval(navigate); - } - - // Network fee from native rawTx. RawTxV1.spends is a ZMap - // serialized as [Name, SpendV1][]. - let totalFeeNicks = 0; - try { - const spends = - rawTx && typeof rawTx === 'object' && 'spends' in rawTx - ? (rawTx as { spends: unknown[] }).spends - : undefined; - if (spends && Array.isArray(spends) && spends.length > 0) { - totalFeeNicks = spends.reduce((sum: number, entry: unknown) => { - const spendV1 = (entry as [unknown, unknown])[1] as { fee?: string }; - const feeValue = spendV1?.fee; - const fee = feeValue ? parseInt(feeValue, 10) : 0; - return sum + (isNaN(fee) ? 0 : fee); - }, 0); + setIsSigning(true); + setSignError(''); + try { + const result = await send<{ success?: boolean; error?: string }>( + INTERNAL_METHODS.APPROVE_SIGN_RAW_TX, + [id] + ); + if (result?.error || !result?.success) { + setSignError(result?.error || 'Transaction could not be signed'); + return; + } + setPendingSignRawTxRequest(null); + closeAfterApproval(navigate); + } catch (error) { + setSignError(error instanceof Error ? error.message : 'Transaction could not be signed'); + } finally { + setIsSigning(false); } - } catch (err) { - console.error('Error calculating fee:', err); } - const totalFeeNocks = nickToNock(totalFeeNicks); - const formattedFee = formatNock(totalFeeNocks); + const formattedFee = formatNicksAsNock(totalFee); const bg = 'var(--color-bg)'; const surface = 'var(--color-surface-800)'; @@ -142,7 +158,7 @@ export function SignRawTxScreen() { {/* Header */}

- Sign Raw Transaction + Review Transaction

@@ -174,25 +190,69 @@ export function SignRawTxScreen() {
- {/* Raw Transaction Content */} +
+ {inputsVerified + ? 'Iris derived this review from the exact transaction and matched every input to an available note in the selected account.' + : `Iris could not match all ${inputCount} inputs to the local wallet cache. The transaction ID, outputs, and fee below come from the exact transaction, but the input values are not verified. Only continue if you trust the requesting site.`} +
+
-
- {notes.map((note: any, index: number) => ( - - ))} +
+

+ {transactionId} +

+ {/* Verified transaction inputs */} + {inputsVerified && ( +
+ +
+ {inputs.map((note: any, index: number) => ( + + ))} +
+
+ )} + + {signError && ( +
+ {signError} +
+ )} + {/* Raw Transaction Outputs */} {outputs && outputs.length > 0 && (
@@ -236,16 +296,16 @@ export function SignRawTxScreen() { style={{ backgroundColor: surface }} >

- {wallet.currentAccount?.name || 'Unknown'} + {signingAccount?.name || 'Unknown'}

- {truncateAddress(wallet.currentAccount?.address)} + {truncateAddress(accountAddress)}

@@ -258,11 +318,21 @@ export function SignRawTxScreen() { className="px-4 py-2.5 shrink-0 flex gap-3" style={{ borderTop: `1px solid ${divider}` }} > - -
diff --git a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx index fc08ba5a..12cd786a 100644 --- a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +++ b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useStore } from '../../store'; import { ChevronRightIcon } from '../../components/icons/ChevronRightIcon'; import { AccountIcon } from '../../components/AccountIcon'; @@ -5,26 +6,39 @@ import { SiteIcon } from '../../components/SiteIcon'; import { truncateAddress } from '../../utils/format'; import { send } from '../../utils/messaging'; import { INTERNAL_METHODS, NOCK_TO_NICKS } from '../../../shared/constants'; -import { formatNock, formatNick } from '../../../shared/currency'; +import { + formatNicks, + formatNicksAsNock, + formatNock, + nicksToBigInt, +} from '../../../shared/currency'; import { useAutoRejectOnClose } from '../../hooks/useAutoRejectOnClose'; import { closeAfterApproval } from '../../utils/displayContext'; export function TransactionApprovalScreen() { const { navigate, pendingTransactionRequest, setPendingTransactionRequest, wallet } = useStore(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [approvalError, setApprovalError] = useState(''); + + useAutoRejectOnClose(pendingTransactionRequest?.id ?? null, INTERNAL_METHODS.REJECT_TRANSACTION); + + useEffect(() => { + if (!pendingTransactionRequest) { + navigate('home'); + } + }, [navigate, pendingTransactionRequest]); if (!pendingTransactionRequest) { - navigate('home'); return null; } - const { id, origin, to, amount } = pendingTransactionRequest; + const { id, origin, to, amount, accountAddress } = pendingTransactionRequest; const fee = pendingTransactionRequest.fee; - const amountNum = Number(amount); - const feeNum = Number(fee); - const totalNum = amountNum + feeNum; + const totalNicks = nicksToBigInt(amount) + nicksToBigInt(fee); + const totalNicksString = totalNicks.toString(); const displayOrigin = origin.includes('://') ? new URL(origin).hostname : origin; - - useAutoRejectOnClose(id, INTERNAL_METHODS.REJECT_TRANSACTION); + const signingAccount = wallet.accounts.find(account => account.address === accountAddress); + const signingAccountBalance = wallet.accountSpendableBalances[accountAddress] ?? 0; async function handleReject() { await send(INTERNAL_METHODS.REJECT_TRANSACTION, [id]); @@ -33,9 +47,26 @@ export function TransactionApprovalScreen() { } async function handleApprove() { - await send(INTERNAL_METHODS.APPROVE_TRANSACTION, [id]); - setPendingTransactionRequest(null); - closeAfterApproval(navigate); + setIsSubmitting(true); + setApprovalError(''); + try { + const result = await send<{ success?: boolean; error?: string }>( + INTERNAL_METHODS.APPROVE_TRANSACTION, + [id] + ); + if (result?.error || !result?.success) { + setApprovalError(result?.error || 'Transaction could not be approved'); + return; + } + setPendingTransactionRequest(null); + closeAfterApproval(navigate); + } catch (error) { + setApprovalError( + error instanceof Error ? error.message : 'Transaction could not be approved' + ); + } finally { + setIsSubmitting(false); + } } const bg = 'var(--color-bg)'; @@ -77,11 +108,10 @@ export function TransactionApprovalScreen() { {/* Amount */}
- {formatNock(amountNum / NOCK_TO_NICKS)}{' '} - NOCK + {formatNicksAsNock(amount)} NOCK
- {formatNick(amountNum)} nicks + {formatNicks(amount)} nicks
@@ -97,13 +127,11 @@ export function TransactionApprovalScreen() {
- - {truncateAddress(wallet.currentAccount?.address)} - + {truncateAddress(accountAddress)}
@@ -120,9 +148,9 @@ export function TransactionApprovalScreen() {
Network fee
-
{formatNock(feeNum / NOCK_TO_NICKS)} NOCK
+
{formatNicksAsNock(fee)} NOCK
- {formatNick(feeNum)} nicks + {formatNicks(fee)} nicks
@@ -130,9 +158,9 @@ export function TransactionApprovalScreen() {
Total
-
{formatNock(totalNum / NOCK_TO_NICKS)} NOCK
+
{formatNicksAsNock(totalNicksString)} NOCK
- {formatNick(totalNum)} nicks + {formatNicks(totalNicksString)} nicks
@@ -140,8 +168,22 @@ export function TransactionApprovalScreen() { {/* Balance After */}
- Balance after: {formatNock(wallet.spendableBalance - totalNum / NOCK_TO_NICKS)} NOCK + Balance after:{' '} + {formatNock(signingAccountBalance - Number(totalNicks) / NOCK_TO_NICKS)} NOCK
+ + {approvalError && ( +
+ {approvalError} +
+ )}
@@ -151,11 +193,21 @@ export function TransactionApprovalScreen() { className="px-4 py-2.5 shrink-0 flex gap-3" style={{ borderTop: `1px solid ${divider}` }} > - -
diff --git a/extension/popup/screens/onboarding/BackupScreen.tsx b/extension/popup/screens/onboarding/BackupScreen.tsx index 61de11d7..8e726beb 100644 --- a/extension/popup/screens/onboarding/BackupScreen.tsx +++ b/extension/popup/screens/onboarding/BackupScreen.tsx @@ -16,14 +16,16 @@ export function BackupScreen() { const [copiedAll, setCopiedAll] = useState(false); async function handleCopyAll() { - if (onboardingMnemonic) { - try { - await navigator.clipboard.writeText(onboardingMnemonic); - setCopiedAll(true); - setTimeout(() => setCopiedAll(false), 2000); - } catch (err) { - console.error('Failed to copy secret phrase:', err); - } + if (!onboardingMnemonic) { + return; + } + + try { + await navigator.clipboard.writeText(onboardingMnemonic); + setCopiedAll(true); + setTimeout(() => setCopiedAll(false), 2000); + } catch (error) { + console.error('Failed to copy secret phrase:', error); } } @@ -231,6 +233,7 @@ export function BackupScreen() { ) : (