diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10a652a4..c094085b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: - name: TypeScript compilation check run: npm run typecheck + - name: Tests + run: npm test + - name: Format check run: npm run format:check diff --git a/extension/background/index.ts b/extension/background/index.ts index 636e3fb5..9b97ba4b 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,13 +25,18 @@ import { ERROR_CODES, ALARM_NAMES, AUTOLOCK_MINUTES, + AUTOLOCK_ALLOWED_MINUTES, STORAGE_KEYS, SESSION_STORAGE_KEYS, USER_ACTIVITY_METHODS, UI_CONSTANTS, APPROVAL_CONSTANTS, CHAIN_ID, + DISPLAY_MODES, + DEFAULT_DISPLAY_MODE, + 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 { @@ -42,6 +46,25 @@ import type { SignRawTxRequest, WalletTransaction, } from '../shared/types'; +import { + SIDE_PANEL_DEFAULT_PATH, + LEGACY_SIGN_RAW_TX_METHOD, + isSidePanelGestureRequest, +} from '../shared/side-panel'; +import { + buildPendingApprovalSessionSnapshot, + restorePendingApprovalSessionSnapshot, + pendingApprovalOriginMatches, + pendingApprovalPermissionStillValid, + pendingApprovalAccountMatches, +} from '../shared/pending-approval-state'; +import { + PendingApprovalSessionPersistence, + loadPendingApprovalSession, +} from '../shared/pending-approvals-session'; +import { BuildRequestAdmission } from '../shared/build-request-admission'; +import { LifecycleTransitionCoordinator } from '../shared/lifecycle-transition-coordinator'; +import { ApprovedOriginsState } from '../shared/approved-origins-state'; const vault = new Vault(); let lastActivity = Date.now(); @@ -50,16 +73,16 @@ 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'; }> = []; // Queued requests -/** - * In-memory cache of approved origins - * Loaded from storage on startup, persisted on changes - */ -let approvedOrigins = new Set(); +const approvedOrigins = new ApprovedOriginsState(async origins => { + await chrome.storage.local.set({ [STORAGE_KEYS.APPROVED_ORIGINS]: origins }); +}); +const pendingApprovalSession = new PendingApprovalSessionPersistence(); /** * Request expiration time (5 minutes) @@ -67,6 +90,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 @@ -95,14 +125,48 @@ const utxoSyncInFlight = new Map< const subwalletDiscoveryInFlight = new Set(); const subwalletDiscoveryAfterInitialSync = new Set(); +// Public builds are read-only but hold the account mutex during WASM work. +// Bound them independently of the send path so one dApp cannot starve signing/sync. +const providerBuildAdmission = new BuildRequestAdmission(1); +const walletLifecycle = new LifecycleTransitionCoordinator(); +let activeExplicitLifecycleGeneration: number | null = null; + +function scheduleCurrentAccountNetworkBootstrap(isCurrent: () => boolean): void { + const account = vault.getCurrentAccount(); + if (!account || !isCurrent() || vault.isLocked()) return; + void (async () => { + if (!(await vault.accountNeedsSyncForCurrentNetwork(account.address))) return; + if ( + !isCurrent() || + vault.isLocked() || + vault.getCurrentAccount()?.address !== account.address + ) { + return; + } + const outcome = await syncAccountUTXOsWithDedupe(account.address, account.name, { + skipHistory: true, + }); + if (!outcome.results[account.address]?.success && isCurrent()) { + console.warn( + '[Background] Post-unlock network sync failed:', + outcome.results[account.address]?.error + ); + } + })().catch(error => console.warn('[Background] Post-unlock network sync failed:', error)); +} + async function clearUnlockSessionCache(): Promise { try { - await chrome.storage.session?.remove(SESSION_STORAGE_KEYS.UNLOCK_CACHE); + await removeUnlockSessionCache(); } catch (error) { console.error('[Background] Failed to clear unlock cache:', error); } } +async function removeUnlockSessionCache(): Promise { + await chrome.storage.session?.remove(SESSION_STORAGE_KEYS.UNLOCK_CACHE); +} + async function persistUnlockSession(): Promise { const sessionStorage = chrome.storage.session; if (!sessionStorage || vault.isLocked()) { @@ -125,49 +189,61 @@ async function persistUnlockSession(): Promise { } async function restoreUnlockSession(): Promise { - const sessionStorage = chrome.storage.session; - if (!sessionStorage) { - return; - } + // Polling messages must not invalidate a password/setup transition merely + // because the Vault intentionally stays locked until its durable work ends. + if (activeExplicitLifecycleGeneration !== null) return; + const lifecycleGeneration = walletLifecycle.begin(); + await walletLifecycle.run(lifecycleGeneration, async isCurrent => { + const sessionStorage = chrome.storage.session; + if (!sessionStorage) { + return; + } - const stored = await sessionStorage.get([SESSION_STORAGE_KEYS.UNLOCK_CACHE]); - const cached = stored[SESSION_STORAGE_KEYS.UNLOCK_CACHE] as UnlockSessionCache['key'] | undefined; + const stored = await sessionStorage.get([SESSION_STORAGE_KEYS.UNLOCK_CACHE]); + const cached = stored[SESSION_STORAGE_KEYS.UNLOCK_CACHE] as + | UnlockSessionCache['key'] + | undefined; - if (!cached || cached.length === 0) { - return; - } - - // Respect manual lock - never auto-unlock if user explicitly locked - if (manuallyLocked) { - await clearUnlockSessionCache(); - return; - } + if (!cached || cached.length === 0) { + return; + } - // Respect auto-lock timeout window - if (autoLockMinutes > 0) { - const idleMs = Date.now() - lastActivity; - if (idleMs >= autoLockMinutes * 60_000) { + // Respect manual lock - never auto-unlock if user explicitly locked + if (manuallyLocked) { await clearUnlockSessionCache(); return; } - } - try { - const key = await crypto.subtle.importKey( - 'raw', - new Uint8Array(cached), - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ); - const result = await vault.unlockWithKey(key); - if ('error' in result) { + // Respect auto-lock timeout window + if (autoLockMinutes > 0) { + const idleMs = Date.now() - lastActivity; + if (idleMs >= autoLockMinutes * 60_000) { + await clearUnlockSessionCache(); + return; + } + } + + try { + const key = await crypto.subtle.importKey( + 'raw', + new Uint8Array(cached), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + const result = await vault.unlockWithKey(key); + if ('error' in result) { + await clearUnlockSessionCache(); + return; + } + if (isCurrent()) { + scheduleCurrentAccountNetworkBootstrap(isCurrent); + } + } catch (error) { + console.error('[Background] Failed to restore unlock session:', error); await clearUnlockSessionCache(); } - } catch (error) { - console.error('[Background] Failed to restore unlock session:', error); - await clearUnlockSessionCache(); - } + }); } async function ensureSessionRestored(): Promise { @@ -188,34 +264,44 @@ async function ensureSessionRestored(): Promise { * Load approved origins from storage */ async function loadApprovedOrigins(): Promise { + const generation = approvedOrigins.captureGeneration(); const stored = await chrome.storage.local.get([STORAGE_KEYS.APPROVED_ORIGINS]); - const origins = stored[STORAGE_KEYS.APPROVED_ORIGINS] || []; - approvedOrigins = new Set(origins); -} + if (!approvedOrigins.isCurrent(generation)) return; + 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)); -/** - * Save approved origins to storage - */ -async function saveApprovedOrigins(): Promise { - await chrome.storage.local.set({ - [STORAGE_KEYS.APPROVED_ORIGINS]: Array.from(approvedOrigins), - }); + approvedOrigins.replace(normalizedOrigins, generation); + + // Older versions stored full page URLs. Persist the least-privilege origin form. + if ( + origins.length !== approvedOrigins.values().size || + origins.some(origin => typeof origin !== 'string' || !approvedOrigins.has(origin)) + ) { + try { + await approvedOrigins.persist(generation); + } catch (error) { + if (!approvedOrigins.isCurrent(generation)) return; + throw error; + } + } } /** * Add an origin to the approved list */ -async function approveOrigin(origin: string): Promise { - approvedOrigins.add(origin); - await saveApprovedOrigins(); +async function approveOrigin(origin: string, generation?: number): Promise { + await approvedOrigins.approve(origin, generation); } /** * Remove an origin from the approved list */ async function revokeOrigin(origin: string): Promise { - approvedOrigins.delete(origin); - await saveApprovedOrigins(); + await approvedOrigins.revoke(origin); } /** @@ -231,6 +317,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) { @@ -240,8 +350,68 @@ 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; + } + void syncPendingApprovalsSession(); +} + +function orphanedProviderResponse(_response: unknown): void { + // The content-script callback is gone after a service worker restart. +} + +async function syncPendingApprovalsSession(): Promise { + const snapshot = buildPendingApprovalSessionSnapshot( + pendingRequests, + currentRequestId, + currentRequestType, + requestQueue, + isRequestExpired + ); + await pendingApprovalSession.persist(snapshot); +} + +async function restorePendingApprovalsSession(): Promise { + const generation = approvedOrigins.captureGeneration(); + const snapshot = await loadPendingApprovalSession(); + if (!snapshot || !approvedOrigins.isCurrent(generation) || pendingApprovalSession.isResetting()) { + return; + } + + const restored = restorePendingApprovalSessionSnapshot(snapshot, isRequestExpired); + pendingRequests.clear(); + + for (const [id, entry] of Object.entries(restored.pending)) { + pendingRequests.set(id, { + request: entry.request, + origin: entry.origin, + tabId: entry.tabId, + documentId: entry.documentId, + sendResponse: orphanedProviderResponse, + restoredWithoutResponder: true, + }); + } + + requestQueue = restored.requestQueue; + currentRequestId = restored.currentRequestId; + currentRequestType = currentRequestId + ? approvalTypeForRequest(pendingRequests.get(currentRequestId)!.request) + : null; + + if ( + currentRequestId && + currentRequestType && + (await getDisplayMode()) === DISPLAY_MODES.SIDE_PANEL + ) { + if ( + !approvedOrigins.isCurrent(generation) || + pendingApprovalSession.isResetting() || + !pendingRequests.has(currentRequestId) + ) { + return; + } + await notifyApprovalPending(currentRequestId, currentRequestType); } } @@ -254,6 +424,132 @@ function isRequestExpired(timestamp: number): boolean { return Date.now() - timestamp > REQUEST_EXPIRATION_MS; } +async function isPendingRequesterActive(request: PendingRequest): Promise { + if (request.tabId === undefined) { + return false; + } + + try { + const tab = await chrome.tabs.get(request.tabId); + + 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. + const response = await chrome.tabs.sendMessage(request.tabId, { + type: RUNTIME_MESSAGE_TYPES.REQUESTER_PING, + }); + return response?.ok === true; + } + + 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 { + const approvalGeneration = approvedOrigins.captureGeneration(); + const approvalIsCurrent = () => + approvedOrigins.isCurrent(approvalGeneration) && pendingRequests.get(requestId) === pending; + + if (!approvalIsCurrent()) { + sendResponse({ error: 'Wallet authorization changed; request the action again' }); + return false; + } + + 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 (!pendingApprovalPermissionStillValid(pending.request, approvedOrigins.values())) { + cancelPendingRequest( + requestId, + 4100, + 'Origin permission was revoked; request the action again' + ); + processNextRequest(); + sendResponse({ error: 'Origin permission was revoked; request the action again' }); + return false; + } + + if (!(await isPendingRequesterActive(pending))) { + if (!approvalIsCurrent()) { + sendResponse({ error: 'Wallet authorization changed; request the action again' }); + return false; + } + cancelPendingRequest(requestId, 4001, 'Requesting page is no longer active'); + processNextRequest(); + sendResponse({ error: 'Requesting page is no longer active' }); + return false; + } + + if (!approvalIsCurrent()) { + sendResponse({ error: 'Wallet authorization changed; request the action again' }); + return false; + } + + if (!pendingApprovalPermissionStillValid(pending.request, approvedOrigins.values())) { + cancelPendingRequest( + requestId, + 4100, + 'Origin permission was revoked; request the action again' + ); + processNextRequest(); + sendResponse({ error: 'Origin permission was revoked; request the action again' }); + return false; + } + + if (isConnectRequest(pending.request) && !pending.request.accountAddress && !vault.isLocked()) { + pending.request.accountAddress = vault.getAddress(); + void syncPendingApprovalsSession(); + } + + if (!pendingApprovalAccountMatches(pending.request, vault.getCurrentAccount()?.address ?? null)) { + cancelPendingRequest(requestId, 4001, 'Selected account changed; request the action again'); + processNextRequest(); + sendResponse({ error: 'Selected account changed; request the action again' }); + return false; + } + + if (!approvalIsCurrent()) { + sendResponse({ error: 'Wallet authorization 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, @@ -271,7 +567,9 @@ function parseNicksParam( typeof input === 'number' ? input : typeof input === 'string' - ? parseInt(input.trim(), 10) + ? /^\d+$/.test(input.trim()) + ? Number(input.trim()) + : NaN : NaN; if ( @@ -310,8 +608,9 @@ function isProviderMethod(method: unknown): method is string { method === PROVIDER_METHODS.SEND_TRANSACTION || method === PROVIDER_METHODS.GET_WALLET_INFO || method === PROVIDER_METHODS.SIGN_TX || + method === PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION || // Legacy v0 API method - method === 'nock_signRawTx' + method === LEGACY_SIGN_RAW_TX_METHOD ); } @@ -423,17 +722,105 @@ interface PendingRequest { sendResponse: (response: any) => void; 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(); +interface ApprovalResetToken { + authorizationGeneration: number; + sessionGeneration: number; +} + +interface ProviderAuthorizationSnapshot { + generation: number; + origin: string; + accountAddress: string; +} + +function clearPendingApprovalMemory(message = 'Wallet reset; request the action again'): void { + const pending = Array.from(pendingRequests.values()); + pendingRequests.clear(); + requestQueue = []; + currentRequestId = null; + currentRequestType = null; + + for (const request of pending) { + try { + request.sendResponse({ error: { code: 4900, message } }); + } catch { + // A service-worker-restored request may no longer have a live responder. + } + } +} + +function beginApprovalStateReset(): ApprovalResetToken { + const token = { + authorizationGeneration: approvedOrigins.beginReset(), + sessionGeneration: pendingApprovalSession.beginReset(), + }; + clearPendingApprovalMemory(); + return token; +} + +function finishApprovalStateReset(token: ApprovalResetToken): void { + // Catch requests that arrived while reset was clearing durable state. Session + // writes stayed blocked during this window, so the final empty memory state + // and the durable null snapshot agree. + clearPendingApprovalMemory(); + pendingApprovalSession.finishReset(token.sessionGeneration); + approvedOrigins.finishReset(token.authorizationGeneration); +} + +function captureProviderAuthorization(origin: string): ProviderAuthorizationSnapshot | null { + const accountAddress = vault.getCurrentAccount()?.address; + if (!accountAddress || vault.isLocked() || !isOriginApproved(origin)) { + return null; + } + return { + generation: approvedOrigins.captureGeneration(), + origin, + accountAddress, + }; +} + +function providerAuthorizationStillValid(snapshot: ProviderAuthorizationSnapshot): boolean { + return ( + approvedOrigins.isCurrent(snapshot.generation) && + isOriginApproved(snapshot.origin) && + !vault.isLocked() && + vault.getCurrentAccount()?.address === snapshot.accountAddress + ); +} + +function pendingApprovalStillCurrent( + requestId: string, + pending: PendingRequest, + generation: number, + accountAddress: string +): boolean { + return ( + approvedOrigins.isCurrent(generation) && + pendingRequests.get(requestId) === pending && + !vault.isLocked() && + vault.getCurrentAccount()?.address === accountAddress + ); +} + /** * Type guard to check if a request is a ConnectRequest */ 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) + ); } /** @@ -451,7 +838,16 @@ 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 && + 'signingIntentId' in request && + 'reviewBlockHeight' in request && + 'accountAddress' in request + ); } /** @@ -463,6 +859,92 @@ 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; + } + + const stored = await chrome.storage.local.get([STORAGE_KEYS.DISPLAY_MODE]); + const mode = stored[STORAGE_KEYS.DISPLAY_MODE]; + return mode === DISPLAY_MODES.POPUP || mode === DISPLAY_MODES.SIDE_PANEL + ? mode + : DEFAULT_DISPLAY_MODE; +} + +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: SIDE_PANEL_DEFAULT_PATH, 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; + } + + try { + if (tabId !== undefined) { + await chrome.sidePanel.setOptions({ + tabId, + path: SIDE_PANEL_DEFAULT_PATH, + enabled: true, + }); + await chrome.sidePanel.open({ tabId }); + return; + } + + const currentWindow = await chrome.windows.getLastFocused({ populate: false }); + if (currentWindow.id !== undefined) { + await chrome.sidePanel.open({ windowId: currentWindow.id }); + } + } catch (error) { + // Panel may already be open, or open() may lack a user gesture — routing still works via message. + console.warn('[Background] sidePanel.open failed (panel may already be open):', error); + } +} + +async function routeApprovalToSidePanel( + requestId: string, + type: ApprovalType, + tabId?: number +): Promise { + await openSidePanelForApproval(tabId); + await notifyApprovalPending(requestId, type); +} + async function createPopupWithFallback(opts: any): Promise { // First try: with left/top try { @@ -480,10 +962,11 @@ async function createPopupWithFallback(opts: any): Promise { * Uses MetaMask pattern: single popup window for all approval requests * Queues requests if user is currently viewing another request */ -async function createApprovalPopup( - requestId: string, - type: 'connect' | 'transaction' | 'sign-message' | 'sign-raw-tx' -) { +async function createApprovalPopup(requestId: string, type: ApprovalType, tabId?: number) { + if (!pendingRequests.has(requestId)) { + return; + } + // If user is currently viewing a different request, queue this one if (currentRequestId !== null && currentRequestId !== requestId) { // Check if already in queue to prevent duplicates @@ -491,11 +974,29 @@ async function createApprovalPopup( if (!alreadyQueued) { requestQueue.push({ id: requestId, type }); } + void syncPendingApprovalsSession(); return; } // Mark this request as currently displayed currentRequestId = requestId; + currentRequestType = type; + + const displayMode = await getDisplayMode(); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } + if (displayMode === DISPLAY_MODES.SIDE_PANEL) { + await openSidePanelForApproval(tabId); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } + await notifyApprovalPending(requestId, type); + if (pendingRequests.has(requestId) && currentRequestId === requestId) { + void syncPendingApprovalsSession(); + } + return; + } let hashPrefix: string; if (type === 'connect') { @@ -513,10 +1014,16 @@ async function createApprovalPopup( if (approvalWindowId !== null) { try { const existingWindow = await chrome.windows.get(approvalWindowId); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } // Window still exists - update it with new request if (existingWindow.tabs && existingWindow.tabs[0]?.id) { await chrome.tabs.update(existingWindow.tabs[0].id, { url: popupUrl }); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } await chrome.windows.update(approvalWindowId, { focused: true }); return; // Done - reused existing window } else { @@ -532,7 +1039,10 @@ 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); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } + return createApprovalPopup(requestId, type, tabId); } // Create new approval window @@ -576,10 +1086,16 @@ async function createApprovalPopup( focused: true, }); - approvalWindowId = newWindow.id || null; + if (pendingRequests.has(requestId) && currentRequestId === requestId) { + approvalWindowId = newWindow.id || null; + } } finally { isCreatingWindow = false; } + + if (pendingRequests.has(requestId) && currentRequestId === requestId) { + void syncPendingApprovalsSession(); + } } /** @@ -588,29 +1104,43 @@ async function createApprovalPopup( */ 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); - 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(); } /** * Emit a wallet event to all tabs * This notifies dApps of wallet state changes (account switches, network changes, etc.) */ -async function emitWalletEvent(eventType: string, data: unknown) { +async function emitWalletEvent( + eventType: string, + data: unknown, + isCurrent: () => boolean = () => true +) { const tabs = await chrome.tabs.query({}); + if (!isCurrent()) return; for (const tab of tabs) { + if (!isCurrent()) return; if (tab.id) { try { await chrome.tabs.sendMessage(tab.id, { @@ -688,14 +1218,16 @@ function scheduleQueuedSubwalletDiscoveryAfterInitialSync(accountAddress: string async function syncAccountUTXOsWithDedupe( accountAddress: string, - accountName = accountAddress + accountName = accountAddress, + options: { skipHistory?: boolean } = {} ): Promise<{ ok: boolean; results: Record; }> { - let inFlight = utxoSyncInFlight.get(accountAddress); + const syncKey = `${accountAddress}:${options.skipHistory ? 'utxo-only' : 'full'}`; + let inFlight = utxoSyncInFlight.get(syncKey); if (!inFlight) { - inFlight = (async () => { + const created = (async () => { const results: Record = {}; if (vault.isLocked()) { @@ -707,7 +1239,7 @@ async function syncAccountUTXOsWithDedupe( } try { - await vault.syncAccountUTXOs(accountAddress); + await vault.syncAccountUTXOs(accountAddress, options); results[accountAddress] = { success: true }; scheduleQueuedSubwalletDiscoveryAfterInitialSync(accountAddress); } catch (syncErr) { @@ -719,10 +1251,13 @@ async function syncAccountUTXOsWithDedupe( } return { ok: true, results }; - })().finally(() => { - utxoSyncInFlight.delete(accountAddress); + })(); + inFlight = created.finally(() => { + if (utxoSyncInFlight.get(syncKey) === inFlight) { + utxoSyncInFlight.delete(syncKey); + } }); - utxoSyncInFlight.set(accountAddress, inFlight); + utxoSyncInFlight.set(syncKey, inFlight); } return inFlight; @@ -737,26 +1272,47 @@ 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]); await loadApprovedOrigins(); await vault.init(); // Load encrypted vault header to detect vault existence - await restoreUnlockSession(); // Rehydrate unlock state if still within auto-lock window + await restorePendingApprovalsSession(); + 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(); } })(); +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) { @@ -790,30 +1346,85 @@ 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 (!chrome.sidePanel || !isSidePanelGestureRequest(msg)) { + return; + } + + const tabId = sender.tab?.id; + if (tabId === undefined || isFromPopup(sender)) { + 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(); const sourcePayload = ((msg || {}).payload || {}) as IncomingRpcRequest; + const requestedMethod = sourcePayload.method; + const shouldPreemptSessionRestore = + isFromPopup(_sender) && + (requestedMethod === INTERNAL_METHODS.LOCK || + requestedMethod === INTERNAL_METHODS.RESET_WALLET); + let preemptiveLock: + | { + generation: number; + lockPromise: Promise<{ ok: boolean }>; + approvalReset?: ApprovalResetToken; + } + | undefined; + if (shouldPreemptSessionRestore) { + manuallyLocked = true; + const generation = walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + preemptiveLock = { + generation, + lockPromise: vault.lock(), + ...(requestedMethod === INTERNAL_METHODS.RESET_WALLET + ? { approvalReset: beginApprovalStateReset() } + : {}), + }; + } + await initPromise; + if (!preemptiveLock) { + await ensureSessionRestored(); + } let payload: any; try { - payload = await bridgeIncomingProviderPayload(sourcePayload); + payload = preemptiveLock ? sourcePayload : await bridgeIncomingProviderPayload(sourcePayload); } catch (err) { sendResponse(toInvalidParamsError(err)); return; } - const sendBridgedResponse = async (response: unknown): Promise => { + const sendBridgedResponse = (response: unknown): void => { try { - sendResponse(await bridgeOutgoingProviderResponse(sourcePayload, response)); + // Provider mapping is synchronous. Keep mapping + sendResponse in one + // turn so a final authorization guard cannot be invalidated between them. + sendResponse(bridgeOutgoingProviderResponse(sourcePayload, response)); } catch (err) { console.error('[Background] Failed to bridge provider response:', err); 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)) { @@ -821,16 +1432,26 @@ 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. + if (!preemptiveLock) { + 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); } } @@ -840,6 +1461,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const connectRequest: ConnectRequest = { id: connectRequestId, origin: connectOrigin, + ...(!vault.isLocked() ? { accountAddress: vault.getAddress() } : {}), timestamp: Date.now(), }; @@ -850,31 +1472,53 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { void sendBridgedResponse(response); }, origin: connectRequest.origin, + tabId: _sender.tab?.id, + documentId: _sender.documentId, }); + void syncPendingApprovalsSession(); + // Create approval popup - await createApprovalPopup(connectRequestId, 'connect'); + await createApprovalPopup(connectRequestId, 'connect', _sender.tab?.id); // Response will be sent when user approves/rejects return; } + const connectAuthorization = captureProviderAuthorization(connectOrigin); + if (!connectAuthorization) { + await sendBridgedResponse({ error: ERROR_CODES.LOCKED }); + return; + } + try { const connectRpcConfig = await getEffectiveRpcConfig(); - await sendBridgedResponse(buildConnectResponse(vault.getAddress(), connectRpcConfig)); + if (!providerAuthorizationStillValid(connectAuthorization)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + await sendBridgedResponse( + buildConnectResponse(connectAuthorization.accountAddress, connectRpcConfig) + ); } catch (err) { console.error('[Background] Failed to build connect response:', err); - await sendBridgedResponse(toInternalProviderError(err)); + await sendBridgedResponse( + providerAuthorizationStillValid(connectAuthorization) + ? toInternalProviderError(err) + : { error: { code: 4100, message: 'Unauthorized origin' } } + ); return; } // Emit connect event when dApp connects successfully - await emitWalletEvent('connect', { chainId: CHAIN_ID }); + await emitWalletEvent('connect', { chainId: CHAIN_ID }, () => + providerAuthorizationStillValid(connectAuthorization) + ); return; 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; @@ -891,10 +1535,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(), }; @@ -905,17 +1562,21 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { void sendBridgedResponse(response); }, origin: signRequest.origin, + tabId: _sender.tab?.id, + documentId: _sender.documentId, }); + void syncPendingApprovalsSession(); + // 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; 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; @@ -932,24 +1593,44 @@ 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(); + // `notes` is an untrusted compatibility sidecar. Approval inputs and + // display data are always recomputed from tx + the encrypted UTXO store. + 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, + signingIntentId: review.signingIntentId, + totalFee: review.totalFee, + reviewBlockHeight: review.blockHeight, + accountAddress: review.accountAddress, + transactionContext: review.transactionContext, timestamp: Date.now(), - } as SignRawTxRequest; + }; // Store pending request with response callback pendingRequests.set(signRawTxId, { @@ -958,17 +1639,21 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { void sendBridgedResponse(response); }, origin: signRawTxRequest.origin, + tabId: _sender.tab?.id, + documentId: _sender.documentId, }); + void syncPendingApprovalsSession(); + // 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; 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; @@ -978,6 +1663,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { await sendBridgedResponse({ error: ERROR_CODES.LOCKED }); return; } + if (!vault.canCurrentAccountSignLocally()) { + await sendBridgedResponse({ + error: { code: 4200, message: 'Current account is external and cannot sign locally' }, + }); + return; + } const sendTxParams = payload.params && typeof payload.params === 'object' ? payload.params : {}; const { to, amount, fee } = sendTxParams; @@ -986,15 +1677,78 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } let amountNicks: Nicks; - let feeNicks: Nicks; + let feeNicks: Nicks | undefined; try { amountNicks = parseNicksParam(amount, 'amount'); - feeNicks = parseNicksParam(fee, 'fee', { allowZero: true }); + feeNicks = + fee === undefined || fee === null + ? undefined // omitted: estimate below, auto-calc exact fee at build time + : parseNicksParam(fee, 'fee', { allowZero: true }); } catch (err) { await sendBridgedResponse(toInvalidParamsError(err)); return; } + // Build once before approval so the UI reviews the exact fee and input + // intent that will later be signed. This read-only step does not reserve + // notes, write history, or derive private key material. + const releaseSendBuildAdmission = providerBuildAdmission.tryAcquire(sendTxOrigin); + if (!releaseSendBuildAdmission) { + await sendBridgedResponse({ + error: { code: -32005, message: 'Another transaction build is already in progress' }, + }); + return; + } + const sendBuildAccountAddress = vault.getCurrentAccount()?.address; + let builtSendTransaction: Awaited>; + try { + builtSendTransaction = await vault.buildSimpleTransaction(to, amountNicks, feeNicks); + } finally { + releaseSendBuildAdmission(); + } + + // Authorization and account selection can change while WASM is building. + // Recheck before inspecting success/error so a revoked caller cannot + // learn wallet-specific build failure details. + if (!isOriginApproved(sendTxOrigin)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + if ( + vault.isLocked() || + !sendBuildAccountAddress || + vault.getCurrentAccount()?.address !== sendBuildAccountAddress + ) { + await sendBridgedResponse({ + error: { code: 4001, message: 'Selected account changed; request the action again' }, + }); + return; + } + if ('error' in builtSendTransaction) { + await sendBridgedResponse({ + error: { code: -32603, message: builtSendTransaction.error }, + }); + return; + } + if (builtSendTransaction.accountAddress !== sendBuildAccountAddress) { + await sendBridgedResponse({ + error: { code: 4001, message: 'Selected account changed; request the action again' }, + }); + return; + } + + const { transactionContext, ...reviewedTransaction } = builtSendTransaction; + + if ( + BigInt(amountNicks) + BigInt(builtSendTransaction.fee) > + BigInt(Number.MAX_SAFE_INTEGER) + ) { + await sendBridgedResponse( + toInvalidParamsError(new Error('Amount plus fee exceeds the supported range')) + ); + return; + } + // Create transaction approval request const txRequestId = crypto.randomUUID(); const txRequest: TransactionRequest = { @@ -1002,7 +1756,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { origin: sendTxOrigin, to, amount: amountNicks, - fee: feeNicks, + fee: builtSendTransaction.fee, + accountAddress: builtSendTransaction.accountAddress, + builtTransaction: reviewedTransaction, + transactionContext, timestamp: Date.now(), }; @@ -1013,17 +1770,21 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { void sendBridgedResponse(response); }, origin: txRequest.origin, + tabId: _sender.tab?.id, + documentId: _sender.documentId, }); + void syncPendingApprovalsSession(); + // Create approval popup - await createApprovalPopup(txRequestId, 'transaction'); + await createApprovalPopup(txRequestId, 'transaction', _sender.tab?.id); // Response will be sent when user approves/rejects return; 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; @@ -1034,106 +1795,369 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } + const walletInfoAuthorization = captureProviderAuthorization(getInfoOrigin); + if (!walletInfoAuthorization) { + await sendBridgedResponse({ error: ERROR_CODES.LOCKED }); + return; + } + try { const walletInfoRpcConfig = await getEffectiveRpcConfig(); - await sendBridgedResponse(buildConnectResponse(vault.getAddress(), walletInfoRpcConfig)); + if (!providerAuthorizationStillValid(walletInfoAuthorization)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + await sendBridgedResponse( + buildConnectResponse(walletInfoAuthorization.accountAddress, walletInfoRpcConfig) + ); } catch (err) { console.error('[Background] Failed to build wallet info response:', err); + await sendBridgedResponse( + providerAuthorizationStillValid(walletInfoAuthorization) + ? toInternalProviderError(err) + : { error: { code: 4100, message: 'Unauthorized origin' } } + ); + } + return; + + case PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION: { + // Read-only like GET_WALLET_INFO: approved origin + unlocked vault, no approval popup. + const buildSimpleOrigin = senderOrigin!; + if (!isOriginApproved(buildSimpleOrigin)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + + if (vault.isLocked()) { + await sendBridgedResponse({ error: ERROR_CODES.LOCKED }); + return; + } + + const buildSimpleParams = + payload.params && typeof payload.params === 'object' ? payload.params : {}; + const { + to: buildSimpleTo, + amount: buildSimpleAmount, + fee: buildSimpleFee, + } = buildSimpleParams; + if (!isNockAddress(buildSimpleTo)) { + await sendBridgedResponse({ error: ERROR_CODES.BAD_ADDRESS }); + return; + } + let buildSimpleAmountNicks: Nicks; + let buildSimpleFeeNicks: Nicks | undefined; + try { + buildSimpleAmountNicks = parseNicksParam(buildSimpleAmount, 'amount'); + buildSimpleFeeNicks = + buildSimpleFee === undefined || buildSimpleFee === null + ? undefined + : parseNicksParam(buildSimpleFee, 'fee', { allowZero: true }); + } catch (err) { + await sendBridgedResponse(toInvalidParamsError(err)); + return; + } + + const releaseBuildAdmission = providerBuildAdmission.tryAcquire(buildSimpleOrigin); + if (!releaseBuildAdmission) { + await sendBridgedResponse({ + error: { code: -32005, message: 'Another transaction build is already in progress' }, + }); + return; + } + const buildAccountAddress = vault.getCurrentAccount()?.address; + + try { + const buildSimpleResult = await vault.buildSimpleTransaction( + buildSimpleTo, + buildSimpleAmountNicks, + buildSimpleFeeNicks + ); + + // Recheck before inspecting success/error so revoked callers cannot + // learn wallet-specific build failure details. + if (!isOriginApproved(buildSimpleOrigin)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + if ( + vault.isLocked() || + !buildAccountAddress || + vault.getCurrentAccount()?.address !== buildAccountAddress + ) { + await sendBridgedResponse({ + error: { code: 4001, message: 'Selected account changed; request the action again' }, + }); + return; + } + if ('error' in buildSimpleResult) { + await sendBridgedResponse({ + error: { code: -32603, message: buildSimpleResult.error }, + }); + return; + } + if (buildSimpleResult.accountAddress !== buildAccountAddress) { + await sendBridgedResponse({ + error: { code: 4001, message: 'Selected account changed; request the action again' }, + }); + return; + } + + const { transactionContext: _transactionContext, ...publicBuildResult } = + buildSimpleResult; + await sendBridgedResponse(publicBuildResult); + } catch (err) { + console.error('[Background] Public unsigned transaction build failed:', err); await sendBridgedResponse(toInternalProviderError(err)); + } finally { + releaseBuildAdmission(); } return; + } // 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; - case INTERNAL_METHODS.UNLOCK: - const unlockResult = await vault.unlock(payload.params?.[0]); // password - sendResponse(unlockResult); + case INTERNAL_METHODS.GET_DISPLAY_MODE: + sendResponse({ mode: await getDisplayMode() }); + return; - // Emit connect event when unlock succeeds - if ('ok' in unlockResult && unlockResult.ok) { - // Clear manual lock flag when successfully unlocked - manuallyLocked = false; - await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); - await persistUnlockSession(); - await emitWalletEvent('connect', { chainId: CHAIN_ID }); + 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; } - return; - case INTERNAL_METHODS.LOCK: - // Set manual lock flag - user explicitly locked, don't auto-unlock - manuallyLocked = true; - await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: true }); - await vault.lock(); - await clearUnlockSessionCache(); - sendResponse({ ok: true }); + const resolvedMode = + requestedMode === DISPLAY_MODES.SIDE_PANEL && chrome.sidePanel + ? DISPLAY_MODES.SIDE_PANEL + : DISPLAY_MODES.POPUP; - // Emit disconnect event when wallet locks - await emitWalletEvent('disconnect', { code: 1013, message: 'Wallet locked' }); + await chrome.storage.local.set({ [STORAGE_KEYS.DISPLAY_MODE]: resolvedMode }); + await applyDisplayMode(resolvedMode); + sendResponse({ success: true, mode: resolvedMode }); return; + } - case INTERNAL_METHODS.RESET_WALLET: - // Reset the wallet completely - clears all data - await vault.reset(); - await clearUnlockSessionCache(); - subwalletDiscoveryAfterInitialSync.clear(); - subwalletDiscoveryInFlight.clear(); - manuallyLocked = false; - sendResponse({ ok: true }); + case INTERNAL_METHODS.GET_PENDING_APPROVAL: + if (currentRequestId && currentRequestType && pendingRequests.has(currentRequestId)) { + sendResponse({ requestId: currentRequestId, approvalType: currentRequestType }); + } else { + sendResponse(null); + } + return; - // Emit disconnect event - await emitWalletEvent('disconnect', { code: 1013, message: 'Wallet reset' }); + case INTERNAL_METHODS.UNLOCK: { + if (activeExplicitLifecycleGeneration !== null) { + sendResponse({ error: 'Another wallet lifecycle action is already in progress' }); + return; + } + const generation = walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + try { + const unlockResult = await walletLifecycle.run(generation, async isCurrent => { + const result = await vault.unlock(payload.params?.[0]); + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + if (!('ok' in result) || !result.ok) return result; + + scheduleCurrentAccountNetworkBootstrap(isCurrent); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; + manuallyLocked = false; + await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; + await persistUnlockSession(); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; + await emitWalletEvent('connect', { chainId: CHAIN_ID }, isCurrent); + return result; + }); + sendResponse(unlockResult ?? { error: ERROR_CODES.LOCKED }); + } finally { + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; + } + } return; + } - case INTERNAL_METHODS.SETUP: - // params: password, mnemonic (optional). If no mnemonic, generates one automatically. - const setupResult = await vault.setup(payload.params?.[0], payload.params?.[1]); + case INTERNAL_METHODS.LOCK: { + manuallyLocked = true; + const generation = preemptiveLock?.generation ?? walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + // The synchronous prefix invalidates signing immediately; durable and + // externally visible effects remain ordered behind older transitions. + const lockPromise = preemptiveLock?.lockPromise ?? vault.lock(); + try { + const lockResult = await walletLifecycle.run(generation, async isCurrent => { + await lockPromise; + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + subwalletDiscoveryAfterInitialSync.clear(); + await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: true }); + await clearUnlockSessionCache(); + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + await emitWalletEvent( + 'disconnect', + { code: 1013, message: 'Wallet locked' }, + isCurrent + ); + return { ok: true }; + }); + sendResponse(lockResult ?? { error: ERROR_CODES.LOCKED }); + } finally { + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; + } + } + return; + } - if ('ok' in setupResult && setupResult.ok) { - manuallyLocked = false; + case INTERNAL_METHODS.RESET_WALLET: { + manuallyLocked = true; + const generation = preemptiveLock?.generation ?? walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + const lockPromise = preemptiveLock?.lockPromise ?? vault.lock(); + const approvalReset = preemptiveLock?.approvalReset ?? beginApprovalStateReset(); + let localStorageResetCompleted = false; + let pendingSessionResetCompleted = false; + let unlockSessionResetCompleted = false; + let resetResult: { ok: true } | { error: string } | undefined; + let resetFailure: unknown; + try { + resetResult = await walletLifecycle.run(generation, async isCurrent => { + await lockPromise; + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + await approvedOrigins.runReset(approvalReset.authorizationGeneration, async () => { + await vault.reset(); + }); + localStorageResetCompleted = true; + pendingSessionResetCompleted = await pendingApprovalSession.clearForReset( + approvalReset.sessionGeneration + ); + if (!pendingSessionResetCompleted) { + throw new Error('Approval-session reset was superseded'); + } + await removeUnlockSessionCache(); + unlockSessionResetCompleted = true; + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + subwalletDiscoveryAfterInitialSync.clear(); + subwalletDiscoveryInFlight.clear(); + utxoSyncInFlight.clear(); + manuallyLocked = false; + await emitWalletEvent('disconnect', { code: 1013, message: 'Wallet reset' }, isCurrent); + return { ok: true }; + }); + } catch (error) { + resetFailure = error; + } finally { + if (!localStorageResetCompleted) { + // Even if the broader vault reset fails or is superseded, never + // leave the old origin permission behind after reset was requested. + try { + await approvedOrigins.runReset(approvalReset.authorizationGeneration, async () => { + await chrome.storage.local.remove(STORAGE_KEYS.APPROVED_ORIGINS); + }); + localStorageResetCompleted = true; + } catch (error) { + resetFailure ??= error; + } + } + if (!pendingSessionResetCompleted) { + try { + pendingSessionResetCompleted = await pendingApprovalSession.clearForReset( + approvalReset.sessionGeneration + ); + if (!pendingSessionResetCompleted) { + throw new Error('Approval-session reset was superseded'); + } + } catch (error) { + resetFailure ??= error; + } + } + if (!unlockSessionResetCompleted) { + try { + await removeUnlockSessionCache(); + unlockSessionResetCompleted = true; + } catch (error) { + resetFailure ??= error; + } + } + + clearPendingApprovalMemory(); + if ( + localStorageResetCompleted && + pendingSessionResetCompleted && + unlockSessionResetCompleted + ) { + finishApprovalStateReset(approvalReset); + } + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; + } + } + if (resetFailure) { + console.error('[Background] Wallet reset failed:', resetFailure); + sendResponse({ + error: resetFailure instanceof Error ? resetFailure.message : 'Wallet reset failed', + }); + } else { + sendResponse(resetResult ?? { error: ERROR_CODES.LOCKED }); + } + return; + } + + case INTERNAL_METHODS.SETUP: { + if (activeExplicitLifecycleGeneration !== null) { + sendResponse({ error: 'Another wallet lifecycle action is already in progress' }); + return; } - // Respond as soon as setup completes — do not await session/local persistence or discovery. - sendResponse(setupResult); + const generation = walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + try { + const setupResult = await walletLifecycle.run(generation, async isCurrent => { + const result = await vault.setup(payload.params?.[0], payload.params?.[1]); + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + if (!('ok' in result) || !result.ok) return result; - if ('ok' in setupResult && setupResult.ok) { - void (async () => { + manuallyLocked = false; await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; await persistUnlockSession(); - })().catch(err => console.error('[Background] Post-SETUP persistence failed:', err)); - // Only scan for existing on-chain sub-wallets when importing a phrase (not brand-new generation). - const importedExistingPhrase = payload.params?.[2] === true; - if (importedExistingPhrase) { - const firstSeedId = vault.getSeedSources()[0]?.id; - if (firstSeedId) { - queueSubwalletDiscoveryAfterInitialSync(firstSeedId); - } - const currentAccount = vault.getCurrentAccount(); - if (currentAccount) { - void syncAccountUTXOsWithDedupe(currentAccount.address, currentAccount.name).catch( - err => console.warn('[Background] Initial imported wallet sync failed:', err) - ); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; + + if (payload.params?.[2] === true) { + const firstSeedId = vault.getSeedSources()[0]?.id; + if (firstSeedId) queueSubwalletDiscoveryAfterInitialSync(firstSeedId); } + scheduleCurrentAccountNetworkBootstrap(isCurrent); + return isCurrent() && !vault.isLocked() ? result : { error: ERROR_CODES.LOCKED }; + }); + sendResponse(setupResult ?? { error: ERROR_CODES.LOCKED }); + } finally { + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; } } return; + } case INTERNAL_METHODS.GET_STATE: // Initialize vault state from storage before checking status @@ -1511,14 +2535,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 }); } @@ -1530,22 +2547,36 @@ 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; } try { - const v2Result = await vault.sendTransactionV2( - txRequest.to, - txRequest.amount, - txRequest.fee, - false, - undefined, - 'provider_send' - ); + let v2Result; + if (txRequest.builtTransaction) { + if (!txRequest.transactionContext) { + throw new Error('Transaction context unavailable; request the action again'); + } + if ( + txRequest.builtTransaction.accountAddress !== txRequest.accountAddress || + txRequest.builtTransaction.to !== txRequest.to || + txRequest.builtTransaction.amount !== txRequest.amount || + txRequest.builtTransaction.fee !== txRequest.fee + ) { + throw new Error('Transaction review changed after approval was requested'); + } + v2Result = await vault.sendBuiltSimpleTransaction( + txRequest.builtTransaction, + txRequest.to, + txRequest.transactionContext, + 'provider_send', + () => isOriginApproved(txRequest.origin) + ); + } else { + // A restored pre-snapshot provider approval cannot be bound to + // exact inputs or re-authorized at the final broadcast boundary. + throw new Error('Transaction review is outdated; request the transaction again'); + } if ('error' in v2Result) { throw new Error(v2Result.error); @@ -1554,7 +2585,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { approveTxPending.sendResponse({ txid: v2Result.txId, amount: txRequest.amount, - fee: txRequest.fee, + // Return the actual fee used by WASM, which may differ from the approval estimate. + fee: String(v2Result.walletTx.fee) as Nicks, }); cancelPendingRequest(approveTxId); processNextRequest(); @@ -1582,6 +2614,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 }); @@ -1596,15 +2632,18 @@ 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 + ); + if (!isOriginApproved(signRequest.origin)) { + throw new Error('Request authorization changed after approval'); + } approveSignPending.sendResponse(signMessageResponse); cancelPendingRequest(approveSignId); processNextRequest(); @@ -1624,6 +2663,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 }); @@ -1639,18 +2682,54 @@ 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 (!signRawTxRequest.transactionContext) { + throw new Error('Transaction review context is unavailable; request approval again'); + } + if ( + currentReview.transactionId !== signRawTxRequest.transactionId || + currentReview.signingIntentId !== signRawTxRequest.signingIntentId || + currentReview.blockHeight !== signRawTxRequest.reviewBlockHeight || + currentReview.accountAddress !== signRawTxRequest.accountAddress || + currentReview.transactionContext.fingerprint !== + signRawTxRequest.transactionContext.fingerprint || + (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, + txEngineSettings: signRawTxRequest.transactionContext.txEngineSettings, + transactionContextFingerprint: signRawTxRequest.transactionContext.fingerprint, + authorizationStillValid: () => isOriginApproved(signRawTxRequest.origin), }); + const signedIntentId = String(wasm.spendsV1Hash(signedTx.spends)); + if (signedIntentId !== signRawTxRequest.signingIntentId) { + throw new Error('Signed transaction does not match the approved transaction'); + } + if ( + vault.isLocked() || + vault.getCurrentAccount()?.address !== signRawTxRequest.accountAddress || + !isOriginApproved(signRawTxRequest.origin) + ) { + throw new Error('Signing account or authorization changed after approval'); + } + approveSignRawTxPending.sendResponse({ tx: signedTx }); cancelPendingRequest(approveSignRawTxId); processNextRequest(); @@ -1672,6 +2751,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 }); @@ -1684,6 +2767,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 }); @@ -1695,33 +2782,75 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const approveConnectPending = pendingRequests.get(approveConnectId); if (approveConnectPending && isConnectRequest(approveConnectPending.request)) { const connectRequest = approveConnectPending.request; + const connectApprovalGeneration = approvedOrigins.captureGeneration(); - // 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 approvedAccountAddress = connectRequest.accountAddress; + if ( + !approvedAccountAddress || + !pendingApprovalStillCurrent( + approveConnectId, + approveConnectPending, + connectApprovalGeneration, + approvedAccountAddress + ) + ) { + throw new Error('Wallet authorization changed; request the action again'); + } + const approveRpcConfig = await getEffectiveRpcConfig(); - const connectResponse = buildConnectResponse(vault.getAddress(), approveRpcConfig); + if ( + !pendingApprovalStillCurrent( + approveConnectId, + approveConnectPending, + connectApprovalGeneration, + approvedAccountAddress + ) + ) { + throw new Error('Wallet authorization changed; request the action again'); + } + const connectResponse = buildConnectResponse(approvedAccountAddress, approveRpcConfig); // Add origin only after the response can be built. - await approveOrigin(connectRequest.origin); + await approveOrigin(connectRequest.origin, connectApprovalGeneration); + if ( + !pendingApprovalStillCurrent( + approveConnectId, + approveConnectPending, + connectApprovalGeneration, + approvedAccountAddress + ) || + !isOriginApproved(connectRequest.origin) + ) { + throw new Error('Wallet authorization changed; request the action again'); + } approveConnectPending.sendResponse(connectResponse); cancelPendingRequest(approveConnectId); processNextRequest(); sendResponse({ success: true }); // Emit connect event - await emitWalletEvent('connect', { chainId: CHAIN_ID }); + await emitWalletEvent('connect', { chainId: CHAIN_ID }, () => + providerAuthorizationStillValid({ + generation: connectApprovalGeneration, + origin: connectRequest.origin, + accountAddress: approvedAccountAddress, + }) + ); } catch (err) { console.error('[Background] Failed to approve connection:', err); const errorMessage = err instanceof Error ? err.message : 'Failed to approve connection'; - cancelPendingRequest(approveConnectId, -32603, errorMessage); - processNextRequest(); + if (pendingRequests.get(approveConnectId) === approveConnectPending) { + cancelPendingRequest(approveConnectId, -32603, errorMessage); + processNextRequest(); + } sendResponse({ error: errorMessage }); } } else { @@ -1733,6 +2862,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 }); @@ -1803,7 +2936,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; - case INTERNAL_METHODS.ESTIMATE_TRANSACTION_FEE: + case INTERNAL_METHODS.ESTIMATE_SEND_FEE: // params: [to, amount] - amount in nicks if (vault.isLocked()) { sendResponse({ error: ERROR_CODES.LOCKED }); @@ -2113,7 +3246,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; @@ -2127,10 +3260,24 @@ chrome.alarms.onAlarm.addListener(async alarm => { const idleMs = Date.now() - lastActivity; if (idleMs >= autoLockMinutes * 60_000) { try { - await vault.lock(); - await clearUnlockSessionCache(); - // Notify popup to update UI immediately - await emitWalletEvent('LOCKED', { reason: 'auto-lock' }); + const generation = walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + const lockPromise = vault.lock(); + try { + await walletLifecycle.run(generation, async isCurrent => { + await lockPromise; + if (!isCurrent()) return; + subwalletDiscoveryAfterInitialSync.clear(); + await clearUnlockSessionCache(); + if (!isCurrent()) return; + // Notify popup to update UI immediately. + await emitWalletEvent('LOCKED', { reason: 'auto-lock' }, isCurrent); + }); + } finally { + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; + } + } } catch (error) { console.error('Auto-lock failed:', error); } diff --git a/extension/content/index.ts b/extension/content/index.ts index 0124ec6c..5e68014c 100644 --- a/extension/content/index.ts +++ b/extension/content/index.ts @@ -5,7 +5,73 @@ * Note: The inpage provider is injected separately via manifest.json with "world": "MAIN" */ -import { MESSAGE_TARGETS } from '../shared/constants'; +import { + DEFAULT_DISPLAY_MODE, + DISPLAY_MODES, + MESSAGE_TARGETS, + RUNTIME_MESSAGE_TYPES, + STORAGE_KEYS, +} from '../shared/constants'; +import type { DisplayMode } from '../shared/constants'; + +type BridgeRequest = { + target: string; + id: string; + payload: { + method: string; + params?: unknown; + api?: unknown; + timeout?: unknown; + }; +}; + +let cachedDisplayMode: DisplayMode | null = null; + +function normalizeDisplayMode(value: unknown): DisplayMode | null { + return value === DISPLAY_MODES.POPUP || value === DISPLAY_MODES.SIDE_PANEL ? value : null; +} + +// Content scripts normally outlive the service worker. Keeping this small cache +// here preserves the user's mode across cold worker starts without awaiting +// storage inside the user-gesture message path. +void chrome.storage.local + .get([STORAGE_KEYS.DISPLAY_MODE]) + .then(stored => { + cachedDisplayMode = + normalizeDisplayMode(stored[STORAGE_KEYS.DISPLAY_MODE]) ?? DEFAULT_DISPLAY_MODE; + }) + .catch(() => { + // An unknown mode fails closed: the worker will not synchronously open a panel. + cachedDisplayMode = null; + }); + +chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local' || !changes[STORAGE_KEYS.DISPLAY_MODE]) { + return; + } + + cachedDisplayMode = + normalizeDisplayMode(changes[STORAGE_KEYS.DISPLAY_MODE].newValue) ?? DEFAULT_DISPLAY_MODE; +}); + +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 +81,38 @@ 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 { + // Rebuild the envelope instead of spreading page-controlled fields. This + // makes runtimeContext authoritative extension context, not dApp metadata. + reply = await chrome.runtime.sendMessage({ + target: data.target, + id: data.id, + payload: data.payload, + runtimeContext: { + displayMode: cachedDisplayMode, + }, + }); + } 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 +120,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 671db3e0..dce46b5d 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/manifest.json b/extension/manifest.json index 8feb01f3..b7bbfa09 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/Popup.tsx b/extension/popup/Popup.tsx index 2e44322c..10662935 100644 --- a/extension/popup/Popup.tsx +++ b/extension/popup/Popup.tsx @@ -37,6 +37,7 @@ export function Popup() { // Handle approval requests from URL hash useApprovalDetection({ + currentScreen, walletAddress: wallet.address, walletLocked: wallet.locked, setPendingConnectRequest, 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..2aab7c12 --- /dev/null +++ b/extension/popup/assets/display-mode-icon.svg @@ -0,0 +1,3 @@ + + + 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/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/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/hooks/useApprovalDetection.ts b/extension/popup/hooks/useApprovalDetection.ts index 9a54bd82..c5f4e89a 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, useRef } 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,8 +21,10 @@ import type { SignRawTxRequest, } from '../../shared/types'; import type { Screen } from '../store'; +import { isSidePanel } from '../utils/displayContext'; interface UseApprovalDetectionProps { + currentScreen: Screen; walletAddress: string | null; walletLocked: boolean; setPendingConnectRequest: (request: ConnectRequest | null) => void; @@ -33,7 +34,28 @@ interface UseApprovalDetectionProps { navigate: (screen: Screen) => void; } +const APPROVAL_SCREENS = new Set([ + 'connect-approval', + 'approve-transaction', + 'sign-message', + 'approve-sign-raw-tx', +]); + +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({ + currentScreen, walletAddress, walletLocked, setPendingConnectRequest, @@ -42,87 +64,177 @@ export function useApprovalDetection({ setPendingSignRawTxRequest, navigate, }: UseApprovalDetectionProps) { + const walletReady = walletAddress !== null; + const walletReadyRef = useRef(walletReady); + walletReadyRef.current = walletReady; + const currentScreenRef = useRef(currentScreen); + currentScreenRef.current = currentScreen; + const approvalVersion = useRef(0); + + const clearPendingApproval = useCallback(() => { + setPendingConnectRequest(null); + setPendingTransactionRequest(null); + setPendingSignRequest(null); + setPendingSignRawTxRequest(null); + + // An empty approval queue should dismiss stale confirmations, without + // interrupting settings, drafts, or a wallet waiting to be unlocked. + if (APPROVAL_SCREENS.has(currentScreenRef.current)) { + navigate(walletLocked ? 'locked' : 'home'); + } + }, [ + walletLocked, + setPendingConnectRequest, + setPendingTransactionRequest, + setPendingSignRequest, + setPendingSignRawTxRequest, + navigate, + ]); + + const handleApproval = useCallback( + async (requestId: string, type: ApprovalType) => { + const version = ++approvalVersion.current; + const targetScreen = getApprovalScreen(type); + const lockedScreen: Screen = 'locked'; + + if (type === 'connect') { + const request = await send(INTERNAL_METHODS.GET_PENDING_CONNECTION, [ + requestId, + ]); + if (version === approvalVersion.current && 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 (version === approvalVersion.current && 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 (version === approvalVersion.current && 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 (version === approvalVersion.current && request && !('error' in request)) { + setPendingSignRawTxRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + }, + [ + walletLocked, + setPendingConnectRequest, + setPendingTransactionRequest, + setPendingSignRequest, + setPendingSignRawTxRequest, + navigate, + ] + ); + + const handleApprovalRef = useRef(handleApproval); + handleApprovalRef.current = handleApproval; + + const fetchPendingApproval = useCallback(async () => { + const version = ++approvalVersion.current; + const pending = await send<{ requestId: string; approvalType: ApprovalType } | null>( + INTERNAL_METHODS.GET_PENDING_APPROVAL + ); + + // A newer check or approval notification supersedes this response. + if (version !== approvalVersion.current) return; + + if (pending === null) { + clearPendingApproval(); + } else if (pending?.requestId && pending.approvalType) { + await handleApproval(pending.requestId, pending.approvalType); + } + }, [clearPendingApproval, handleApproval]); + + // Side panel: listen for approval messages immediately (may arrive before wallet init) + useEffect(() => { + if (!isSidePanel()) return; + + const handleRuntimeMessage = (message: { + type?: string; + requestId?: string; + approvalType?: ApprovalType; + }) => { + if ( + message?.type === RUNTIME_MESSAGE_TYPES.APPROVAL_PENDING && + message.requestId && + message.approvalType + ) { + if (walletReadyRef.current) { + void handleApprovalRef + .current(message.requestId, message.approvalType) + .catch(console.error); + } + } + }; + + chrome.runtime.onMessage.addListener(handleRuntimeMessage); + return () => chrome.runtime.onMessage.removeListener(handleRuntimeMessage); + }, []); + + // Side panel: fetch the current approval once the wallet is ready + useEffect(() => { + if (!isSidePanel() || !walletReady) return; + + // Re-query even if a notification arrived during initialization: that + // request may have been resolved by another panel before we became ready. + void fetchPendingApproval().catch(console.error); + }, [walletReady, fetchPendingApproval]); + + // Side panel: re-check when panel becomes visible (e.g. reopened or refocused) + useEffect(() => { + if (!isSidePanel() || !walletReady) return; + + const handleVisibilityChange = () => { + if (document.visibilityState !== 'visible') return; + + void fetchPendingApproval().catch(console.error); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [walletReady, fetchPendingApproval]); + + // Popup windows: route via URL hash useEffect(() => { - // Wait for wallet state to be initialized - if (walletAddress === null) return; + if (!walletReady || isSidePanel()) return; const hash = window.location.hash.slice(1); // Remove '#' 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, - ]); + }, [walletReady, handleApproval]); } 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/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..adbe78b7 --- /dev/null +++ b/extension/popup/screens/DisplayModeScreen.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from 'react'; +import { useStore } from '../store'; +import { send } from '../utils/messaging'; +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'; +import { SIDE_PANEL_DEFAULT_PATH } from '../../shared/side-panel'; + +export function DisplayModeScreen() { + const { navigate } = useStore(); + const [mode, setMode] = useState(DEFAULT_DISPLAY_MODE); + 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.setOptions({ + tabId: activeTab.id, + path: SIDE_PANEL_DEFAULT_PATH, + enabled: true, + }); + 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 c27eb124..906104dd 100644 --- a/extension/popup/screens/HomeScreen.tsx +++ b/extension/popup/screens/HomeScreen.tsx @@ -435,7 +435,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..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(() => { @@ -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..000e525c 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,10 @@ 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..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 = @@ -163,7 +188,7 @@ export function RpcSettingsScreen() { return (
{/* Header */} @@ -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/SendReviewScreen.tsx b/extension/popup/screens/SendReviewScreen.tsx index cfd4d9c1..cd07f3e8 100644 --- a/extension/popup/screens/SendReviewScreen.tsx +++ b/extension/popup/screens/SendReviewScreen.tsx @@ -103,7 +103,7 @@ export function SendReviewScreen() { return (
{/* Header */} @@ -130,7 +130,7 @@ export function SendReviewScreen() { {/* Content */}
diff --git a/extension/popup/screens/SendScreen.tsx b/extension/popup/screens/SendScreen.tsx index 04736c12..589a6397 100644 --- a/extension/popup/screens/SendScreen.tsx +++ b/extension/popup/screens/SendScreen.tsx @@ -413,7 +413,7 @@ export function SendScreen() { const amountNicks = Math.floor(amountNum * NOCK_TO_NICKS); const result = await send<{ fee?: number; error?: string }>( - INTERNAL_METHODS.ESTIMATE_TRANSACTION_FEE, + INTERNAL_METHODS.ESTIMATE_SEND_FEE, [addressToUse, amountNicks] ); @@ -453,7 +453,7 @@ export function SendScreen() { return (
{/* Header */} diff --git a/extension/popup/screens/SendSubmittedScreen.tsx b/extension/popup/screens/SendSubmittedScreen.tsx index 3c3b88c9..7bc8d880 100644 --- a/extension/popup/screens/SendSubmittedScreen.tsx +++ b/extension/popup/screens/SendSubmittedScreen.tsx @@ -104,10 +104,7 @@ export function SendSubmittedScreen() { } return ( -
+
{/* Header */}
{/* Content */} -
+
{/* Success Section */}
diff --git a/extension/popup/screens/SettingsScreen.tsx b/extension/popup/screens/SettingsScreen.tsx index e15ab17e..73eee334 100644 --- a/extension/popup/screens/SettingsScreen.tsx +++ b/extension/popup/screens/SettingsScreen.tsx @@ -1,17 +1,21 @@ 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'; import TransferV0Icon from '../assets/transferv0_icon.svg'; import { CloseIcon } from '../components/icons/CloseIcon'; import { ChevronRightIcon } from '../components/icons/ChevronRightIcon'; +import { AccountIcon } from '../components/AccountIcon'; 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(); + const { navigate, wallet } = useStore(); + const currentAccount = + wallet.currentAccount ?? wallet.accounts.find(account => !account.hidden) ?? wallet.accounts[0]; function handleClose() { navigate('home'); @@ -19,6 +23,9 @@ export function SettingsScreen() { function handleThemeSettings() { navigate('theme-settings'); } + function handleDisplayMode() { + navigate('display-mode'); + } function handleKeySettings() { navigate('key-settings'); } @@ -65,7 +72,7 @@ export function SettingsScreen() { return (
{/* Header */} @@ -74,7 +81,11 @@ export function SettingsScreen() { style={{ backgroundColor: 'var(--color-bg)' }} >
- Iris +

Settings

-
+
{/* Icon and instructions - same as ImportScreen */} diff --git a/extension/popup/screens/ViewSecretPhraseScreen.tsx b/extension/popup/screens/ViewSecretPhraseScreen.tsx index 611097cd..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); } @@ -65,7 +74,7 @@ export function ViewSecretPhraseScreen() { return (
{/* Header */} @@ -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/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..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); @@ -149,7 +126,7 @@ export function WalletStylingScreen() { return (
{/* Header */} @@ -173,7 +150,7 @@ export function WalletStylingScreen() {
{/* Content */} -
+
{/* Preview */}
diff --git a/extension/popup/screens/approvals/ConnectApprovalScreen.tsx b/extension/popup/screens/approvals/ConnectApprovalScreen.tsx index a4e9a3ab..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'; @@ -5,30 +6,59 @@ 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(); + 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]); setPendingConnectRequest(null); - window.close(); - } - - async function handleConnect() { - await send(INTERNAL_METHODS.APPROVE_CONNECTION, [id]); - setPendingConnectRequest(null); - window.close(); + closeAfterApproval(navigate); } const bg = 'var(--color-bg)'; @@ -40,10 +70,7 @@ export function ConnectApprovalScreen() { return (
-
+
{/* Header */}

@@ -94,20 +121,33 @@ export function ConnectApprovalScreen() { style={{ backgroundColor: surface }} >

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

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

+ + {connectionError && ( +
+ {connectionError} +
+ )}
@@ -116,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 ce69fa7a..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'; @@ -5,29 +6,53 @@ 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(); + 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]); setPendingSignRequest(null); - window.close(); + closeAfterApproval(navigate); } async function handleSign() { - await send(INTERNAL_METHODS.APPROVE_SIGN_MESSAGE, [id]); - setPendingSignRequest(null); - window.close(); + 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)'; @@ -38,10 +63,7 @@ export function SignMessageScreen() { return (
-
+
{/* Header */}

@@ -105,20 +127,33 @@ export function SignMessageScreen() { style={{ backgroundColor: surface }} >

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

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

+ + {signError && ( +
+ {signError} +
+ )}
@@ -127,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 db7a7818..a016d46c 100644 --- a/extension/popup/screens/approvals/SignRawTxScreen.tsx +++ b/extension/popup/screens/approvals/SignRawTxScreen.tsx @@ -4,10 +4,11 @@ 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'; -import { nickToNock, formatNock } from '../../../shared/currency'; +import { formatNicksAsNock } from '../../../shared/currency'; interface NoteItemProps { note: any; @@ -42,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 || ''; @@ -53,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 ( @@ -64,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 && ( @@ -76,7 +81,7 @@ function NoteItem({ note, type, textPrimary, textMuted, surface }: NoteItemProps Copied! )} - +
); @@ -84,50 +89,62 @@ 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, + signingIntentId, + totalFee, + accountAddress, + } = pendingSignRawTxRequest; + const signingAccount = wallet.accounts.find(account => account.address === accountAddress); 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(); - } - - // 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)'; @@ -137,14 +154,11 @@ export function SignRawTxScreen() { return (
-
+
{/* Header */}

- Sign Raw Transaction + Review Transaction

@@ -176,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 intent, 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) => ( - - ))} +
+

+ {signingIntentId} +

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

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

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

@@ -260,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 753211e7..b85da846 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,36 +6,68 @@ 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 isFeeEstimated = Boolean(pendingTransactionRequest.feeEstimated); + 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]); setPendingTransactionRequest(null); - window.close(); + closeAfterApproval(navigate); } async function handleApprove() { - await send(INTERNAL_METHODS.APPROVE_TRANSACTION, [id]); - setPendingTransactionRequest(null); - window.close(); + 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)'; @@ -45,10 +78,7 @@ export function TransactionApprovalScreen() { return (
-
+
{/* Header */}

@@ -79,11 +109,10 @@ export function TransactionApprovalScreen() { {/* Amount */}
- {formatNock(amountNum / NOCK_TO_NICKS)}{' '} - NOCK + {formatNicksAsNock(amount)} NOCK
- {formatNick(amountNum)} nicks + {formatNicks(amount)} nicks
@@ -99,13 +128,11 @@ export function TransactionApprovalScreen() {

- - {truncateAddress(wallet.currentAccount?.address)} - + {truncateAddress(accountAddress)}
@@ -120,30 +147,59 @@ export function TransactionApprovalScreen() { {/* Fee & Total */}
- Network fee + Network fee{isFeeEstimated ? ' (estimated)' : ''}
-
{formatNock(feeNum / NOCK_TO_NICKS)} NOCK
+
+ {isFeeEstimated ? '~' : ''} + {formatNicksAsNock(fee)} NOCK +
- {formatNick(feeNum)} nicks + {isFeeEstimated ? '~' : ''} + {formatNicks(fee)} nicks
- Total + Total{isFeeEstimated ? ' (estimated)' : ''}
-
{formatNock(totalNum / NOCK_TO_NICKS)} NOCK
+
+ {isFeeEstimated ? '~' : ''} + {formatNicksAsNock(totalNicksString)} NOCK +
- {formatNick(totalNum)} nicks + {isFeeEstimated ? '~' : ''} + {formatNicks(totalNicksString)} nicks
+ {isFeeEstimated && ( +
+ The final network fee is calculated when the transaction is built and may differ + from this estimate. +
+ )} + {/* Balance After */}
- Balance after: {formatNock(wallet.spendableBalance - totalNum / NOCK_TO_NICKS)} NOCK + {isFeeEstimated ? 'Estimated balance after' : 'Balance after'}:{' '} + {formatNock(signingAccountBalance - Number(totalNicks) / NOCK_TO_NICKS)} NOCK
+ + {approvalError && ( +
+ {approvalError} +
+ )}
@@ -153,11 +209,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 f64c351a..8e726beb 100644 --- a/extension/popup/screens/onboarding/BackupScreen.tsx +++ b/extension/popup/screens/onboarding/BackupScreen.tsx @@ -16,21 +16,23 @@ 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); } } if (!onboardingMnemonic) { // Should never happen, but handle gracefully return ( -
+
No mnemonic found. Please restart onboarding.