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..39d89542 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,21 @@ 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, + pendingApprovalAccountMatches, +} from '../shared/pending-approval-state'; +import { + persistPendingApprovalSession, + loadPendingApprovalSession, +} from '../shared/pending-approvals-session'; const vault = new Vault(); let lastActivity = Date.now(); @@ -50,6 +69,7 @@ let manuallyLocked = false; // Track if user manually locked (don't auto-unlock) let approvalWindowId: number | null = null; // Track the approval popup window for reuse let isCreatingWindow = false; // Prevent race condition when creating window let currentRequestId: string | null = null; // Currently displayed request +let currentRequestType: ApprovalType | null = null; // Type of currently displayed request let requestQueue: Array<{ id: string; type: 'connect' | 'transaction' | 'sign-message' | 'sign-raw-tx'; @@ -67,6 +87,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 @@ -189,8 +216,22 @@ async function ensureSessionRestored(): Promise { */ async function loadApprovedOrigins(): Promise { const stored = await chrome.storage.local.get([STORAGE_KEYS.APPROVED_ORIGINS]); - const origins = stored[STORAGE_KEYS.APPROVED_ORIGINS] || []; - approvedOrigins = new Set(origins); + const origins = Array.isArray(stored[STORAGE_KEYS.APPROVED_ORIGINS]) + ? (stored[STORAGE_KEYS.APPROVED_ORIGINS] as unknown[]) + : []; + const normalizedOrigins = origins + .map(origin => normalizeWebOrigin(origin)) + .filter((origin): origin is string => Boolean(origin)); + + approvedOrigins = new Set(normalizedOrigins); + + // Older versions stored full page URLs. Persist the least-privilege origin form. + if ( + origins.length !== approvedOrigins.size || + origins.some(origin => typeof origin !== 'string' || !approvedOrigins.has(origin)) + ) { + await saveApprovedOrigins(); + } } /** @@ -231,6 +272,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 +305,60 @@ 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 persistPendingApprovalSession(snapshot); +} + +async function restorePendingApprovalsSession(): Promise { + const snapshot = await loadPendingApprovalSession(); + if (!snapshot) { + 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 + ) { + await notifyApprovalPending(currentRequestId, currentRequestType); } } @@ -254,6 +371,90 @@ 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 (!pendingApprovalOriginMatches(request.request, normalizeWebOrigin(tab.url))) { + return false; + } + + if (!request.documentId) { + // Legacy snapshots lack a document ID. At least require the same tab origin + // and a live Iris content script before allowing a sensitive operation. + 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 { + if (isRequestExpired(pending.request.timestamp)) { + cancelPendingRequest(requestId, 4003, 'Request expired'); + processNextRequest(); + sendResponse({ error: 'Request expired' }); + return false; + } + + if (pending.restoredWithoutResponder) { + cancelPendingRequest( + requestId, + 4900, + 'The wallet restarted while this request was pending; request the action again' + ); + processNextRequest(); + sendResponse({ + error: 'The wallet restarted while this request was pending; request the action again', + }); + return false; + } + + if (pending.processing) { + sendResponse({ error: 'Request is already being processed' }); + return false; + } + pending.processing = true; + + if (!(await isPendingRequesterActive(pending))) { + cancelPendingRequest(requestId, 4001, 'Requesting page is no longer active'); + processNextRequest(); + sendResponse({ error: 'Requesting page is no longer active' }); + return false; + } + + if (isConnectRequest(pending.request) && !pending.request.accountAddress && !vault.isLocked()) { + pending.request.accountAddress = vault.getAddress(); + void syncPendingApprovalsSession(); + } + + if (!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; + } + + return true; +} + /** Parse amount/fee from message boundary. Returns Nicks (string). Throws on invalid input. */ function parseNicksParam( input: unknown, @@ -271,7 +472,9 @@ function parseNicksParam( typeof input === 'number' ? input : typeof input === 'string' - ? parseInt(input.trim(), 10) + ? /^\d+$/.test(input.trim()) + ? Number(input.trim()) + : NaN : NaN; if ( @@ -311,7 +514,7 @@ function isProviderMethod(method: unknown): method is string { method === PROVIDER_METHODS.GET_WALLET_INFO || method === PROVIDER_METHODS.SIGN_TX || // Legacy v0 API method - method === 'nock_signRawTx' + method === LEGACY_SIGN_RAW_TX_METHOD ); } @@ -423,6 +626,12 @@ 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(); @@ -433,7 +642,9 @@ const pendingRequests = new Map(); function isConnectRequest( request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest ): request is ConnectRequest { - return 'timestamp' in request && !('message' in request) && !('to' in request); + return ( + 'timestamp' in request && !('message' in request) && !('to' in request) && !('rawTx' in request) + ); } /** @@ -451,7 +662,15 @@ function isSignRequest( function isSignRawTxRequest( request: TransactionRequest | SignRequest | ConnectRequest | SignRawTxRequest ): request is SignRawTxRequest { - return 'rawTx' in request && 'notes' in request && 'spendConditions' in request; + return ( + 'rawTx' in request && + 'inputs' in request && + 'inputsVerified' in request && + 'inputCount' in request && + 'transactionId' in request && + 'reviewBlockHeight' in request && + 'accountAddress' in request + ); } /** @@ -463,6 +682,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 +785,7 @@ 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 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 +793,20 @@ 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 (displayMode === DISPLAY_MODES.SIDE_PANEL) { + await routeApprovalToSidePanel(requestId, type, tabId); + void syncPendingApprovalsSession(); + return; + } let hashPrefix: string; if (type === 'connect') { @@ -532,7 +843,7 @@ async function createApprovalPopup( // Prevent race condition: if window is being created, wait and retry if (isCreatingWindow) { await new Promise(resolve => setTimeout(resolve, 100)); - return createApprovalPopup(requestId, type); + return createApprovalPopup(requestId, type, tabId); } // Create new approval window @@ -580,6 +891,8 @@ async function createApprovalPopup( } finally { isCreatingWindow = false; } + + void syncPendingApprovalsSession(); } /** @@ -588,19 +901,27 @@ 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(); } /** @@ -737,26 +1058,49 @@ 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 restorePendingApprovalsSession(); await restoreUnlockSession(); // Rehydrate unlock state if still within auto-lock window - // Only schedule alarm if auto-lock is enabled, otherwise ensure any stale alarm is cleared - if (autoLockMinutes > 0) { - scheduleAlarm(); + await applyDisplayMode(await getDisplayMode()); + + 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,10 +1134,33 @@ 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(); @@ -813,7 +1180,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse(toInternalProviderError(err)); } }; - await touchActivity(payload?.method); + const senderOrigin = getSenderOrigin(_sender); + if (isProviderMethod(payload?.method) && !senderOrigin) { + await sendBridgedResponse({ error: ERROR_CODES.UNAUTHORIZED }); + return; + } // Guard: internal methods (wallet:*) can only be called from popup/extension pages if (payload?.method?.startsWith('wallet:') && !isFromPopup(_sender)) { @@ -821,16 +1192,24 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } + // Only authorized extension-page methods can extend the unlock window. + // Page-originated provider traffic is not proof of user activity. + await touchActivity(payload?.method); + switch (payload?.method) { // Provider methods (called from injected provider via content script) case PROVIDER_METHODS.CONNECT: - const connectOrigin = _sender.url || _sender.origin || ''; + const connectOrigin = senderOrigin!; // Check if origin is already approved if (!isOriginApproved(connectOrigin) || vault.isLocked()) { // Clear any existing pending unlock requests from same origin to prevent duplicates for (const [existingId, existingData] of pendingRequests.entries()) { - if (existingData.origin === connectOrigin) { + if ( + existingData.origin === connectOrigin && + isConnectRequest(existingData.request) && + !existingData.processing + ) { cancelPendingRequest(existingId); } } @@ -840,6 +1219,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const connectRequest: ConnectRequest = { id: connectRequestId, origin: connectOrigin, + ...(!vault.isLocked() ? { accountAddress: vault.getAddress() } : {}), timestamp: Date.now(), }; @@ -850,10 +1230,14 @@ 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; @@ -874,7 +1258,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { case PROVIDER_METHODS.SIGN_MESSAGE: // Validate origin - const signMessageOrigin = _sender.url || _sender.origin || ''; + const signMessageOrigin = senderOrigin!; if (!isOriginApproved(signMessageOrigin)) { await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); return; @@ -891,10 +1275,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 +1302,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 +1333,40 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { await sendBridgedResponse({ error: { code: -32602, message: 'Invalid params' } }); return; } - await ensureWasmInitialized(); - const nativeRawTx = wasm.nockchainTxToRawTx(signTxParams.tx); - const nativeNotes = (signTxParams.notes ?? []) as Note[]; - const nativeSpendConditions = wasm.rawTxInputSpendConditions(nativeRawTx); - assertNativeRawTx(nativeRawTx); - - const outputs = await vault.computeOutputs(nativeRawTx); + let nativeRawTx: unknown; + let review: Awaited>; + try { + await ensureWasmInitialized(); + nativeRawTx = wasm.nockchainTxToRawTx(signTxParams.tx); + assertNativeRawTx(nativeRawTx); + review = await vault.describeRawTxForApproval(nativeRawTx); + } catch (error) { + console.warn('[Background] Rejected unverifiable raw transaction:', error); + await sendBridgedResponse({ + error: { + code: -32602, + message: + error instanceof Error ? error.message : 'Transaction inputs could not be verified', + }, + }); + return; + } const signRawTxId = crypto.randomUUID(); const signRawTxRequest: SignRawTxRequest = { id: signRawTxId, origin: signRawTxOrigin, rawTx: nativeRawTx, - notes: nativeNotes, - spendConditions: nativeSpendConditions, - outputs: outputs, + inputs: review.inputs, + inputsVerified: review.inputsVerified, + inputCount: review.inputCount, + outputs: review.outputs, + transactionId: review.transactionId, + totalFee: review.totalFee, + reviewBlockHeight: review.blockHeight, + accountAddress: review.accountAddress, timestamp: Date.now(), - } as SignRawTxRequest; + }; // Store pending request with response callback pendingRequests.set(signRawTxId, { @@ -958,17 +1375,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; @@ -990,6 +1411,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { try { amountNicks = parseNicksParam(amount, 'amount'); feeNicks = parseNicksParam(fee, 'fee', { allowZero: true }); + if (Number(amountNicks) + Number(feeNicks) > Number.MAX_SAFE_INTEGER) { + throw new Error('Amount plus fee exceeds the supported range'); + } } catch (err) { await sendBridgedResponse(toInvalidParamsError(err)); return; @@ -1003,6 +1427,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { to, amount: amountNicks, fee: feeNicks, + accountAddress: vault.getAddress(), timestamp: Date.now(), }; @@ -1013,17 +1438,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; @@ -1046,24 +1475,56 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Internal methods (called from popup) case INTERNAL_METHODS.SET_AUTO_LOCK: const newMinutes = payload.params?.[0]; - autoLockMinutes = typeof newMinutes === 'number' ? newMinutes : Number(newMinutes) || 0; + const normalizedMinutes = normalizeAutoLockMinutes(newMinutes); + if (Number(newMinutes) !== normalizedMinutes) { + sendResponse({ error: ERROR_CODES.INVALID_PARAMS }); + return; + } + autoLockMinutes = normalizedMinutes; await chrome.storage.local.set({ [STORAGE_KEYS.AUTO_LOCK_MINUTES]: autoLockMinutes, }); - // Start or stop alarm based on setting - if (autoLockMinutes > 0) { - // Reset activity timestamp when enabling auto-lock - lastActivity = Date.now(); - await chrome.storage.local.set({ [STORAGE_KEYS.LAST_ACTIVITY]: lastActivity }); - scheduleAlarm(); + lastActivity = Date.now(); + await chrome.storage.local.set({ [STORAGE_KEYS.LAST_ACTIVITY]: lastActivity }); + if (autoLockMinutes === 0) { + await chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); } else { - // Clear alarm when disabling auto-lock - chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); + scheduleAlarm(); } sendResponse({ ok: true }); return; + case INTERNAL_METHODS.GET_DISPLAY_MODE: + sendResponse({ mode: await getDisplayMode() }); + return; + + case INTERNAL_METHODS.SET_DISPLAY_MODE: { + const requestedMode = payload.params?.[0]; + if (requestedMode !== DISPLAY_MODES.POPUP && requestedMode !== DISPLAY_MODES.SIDE_PANEL) { + sendResponse({ error: ERROR_CODES.INVALID_PARAMS }); + return; + } + + const resolvedMode = + requestedMode === DISPLAY_MODES.SIDE_PANEL && chrome.sidePanel + ? DISPLAY_MODES.SIDE_PANEL + : DISPLAY_MODES.POPUP; + + await chrome.storage.local.set({ [STORAGE_KEYS.DISPLAY_MODE]: resolvedMode }); + await applyDisplayMode(resolvedMode); + sendResponse({ success: true, mode: resolvedMode }); + return; + } + + case INTERNAL_METHODS.GET_PENDING_APPROVAL: + if (currentRequestId && currentRequestType && pendingRequests.has(currentRequestId)) { + sendResponse({ requestId: currentRequestId, approvalType: currentRequestType }); + } else { + sendResponse(null); + } + return; + case INTERNAL_METHODS.UNLOCK: const unlockResult = await vault.unlock(payload.params?.[0]); // password sendResponse(unlockResult); @@ -1511,14 +1972,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,10 +1984,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (approveTxPending && isTransactionRequest(approveTxPending.request)) { const txRequest = approveTxPending.request; - // Check if request has expired (replay prevention) - if (isRequestExpired(txRequest.timestamp)) { - cancelPendingRequest(approveTxId, 4003, 'Request expired'); - sendResponse({ error: 'Request expired' }); + if (!(await validatePendingApproval(approveTxId, approveTxPending, sendResponse))) { return; } @@ -1544,7 +1995,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { txRequest.fee, false, undefined, - 'provider_send' + 'provider_send', + txRequest.accountAddress ); if ('error' in v2Result) { @@ -1582,6 +2034,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 +2052,15 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (approveSignPending && isSignRequest(approveSignPending.request)) { const signRequest = approveSignPending.request; - // Check if request has expired (replay prevention) - if (isRequestExpired(signRequest.timestamp)) { - cancelPendingRequest(approveSignId, 4003, 'Request expired'); - sendResponse({ error: 'Request expired' }); + if (!(await validatePendingApproval(approveSignId, approveSignPending, sendResponse))) { return; } try { - const signMessageResponse = await vault.signMessage([signRequest.message]); + const signMessageResponse = await vault.signMessage( + [signRequest.message], + signRequest.accountAddress + ); approveSignPending.sendResponse(signMessageResponse); cancelPendingRequest(approveSignId); processNextRequest(); @@ -1624,6 +2080,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 +2099,39 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (approveSignRawTxPending && isSignRawTxRequest(approveSignRawTxPending.request)) { const signRawTxRequest = approveSignRawTxPending.request; - // Check if request has expired (replay prevention) - if (isRequestExpired(signRawTxRequest.timestamp)) { - cancelPendingRequest(approveSignRawTxId, 4003, 'Request expired'); - sendResponse({ error: 'Request expired' }); + if ( + !(await validatePendingApproval( + approveSignRawTxId, + approveSignRawTxPending, + sendResponse + )) + ) { return; } try { assertNativeRawTx(signRawTxRequest.rawTx); + const currentReview = await vault.describeRawTxForApproval(signRawTxRequest.rawTx); + if ( + currentReview.transactionId !== signRawTxRequest.transactionId || + currentReview.blockHeight !== signRawTxRequest.reviewBlockHeight || + currentReview.accountAddress !== signRawTxRequest.accountAddress || + (signRawTxRequest.inputsVerified && !currentReview.inputsVerified) + ) { + throw new Error('Transaction review changed after approval was requested'); + } + const signedTx = await vault.signRawTx({ rawTx: signRawTxRequest.rawTx, + blockHeight: signRawTxRequest.reviewBlockHeight, + accountAddress: signRawTxRequest.accountAddress, }); + const signedRawTx = wasm.nockchainTxToRawTx(signedTx); + const signedTransactionId = String(wasm.rawTxId(signedRawTx)); + if (signedTransactionId !== signRawTxRequest.transactionId) { + throw new Error('Signed transaction does not match the approved transaction'); + } + approveSignRawTxPending.sendResponse({ tx: signedTx }); cancelPendingRequest(approveSignRawTxId); processNextRequest(); @@ -1672,6 +2153,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 +2169,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 }); @@ -1696,16 +2185,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (approveConnectPending && isConnectRequest(approveConnectPending.request)) { const connectRequest = approveConnectPending.request; - // Check if request has expired (replay prevention) - if (isRequestExpired(connectRequest.timestamp)) { - cancelPendingRequest(approveConnectId, 4003, 'Request expired'); - sendResponse({ error: 'Request expired' }); + if ( + !(await validatePendingApproval(approveConnectId, approveConnectPending, sendResponse)) + ) { return; } try { const approveRpcConfig = await getEffectiveRpcConfig(); - const connectResponse = buildConnectResponse(vault.getAddress(), approveRpcConfig); + const connectResponse = buildConnectResponse( + connectRequest.accountAddress!, + approveRpcConfig + ); // Add origin only after the response can be built. await approveOrigin(connectRequest.origin); @@ -1733,6 +2224,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 }); @@ -2113,7 +2608,7 @@ chrome.alarms.onAlarm.addListener(async alarm => { await initPromise; await ensureSessionRestored(); - // Don't auto-lock if set to "never" (0 minutes) - stop the alarm cycle + // Zero is the explicit "Never" preference. if (autoLockMinutes <= 0) { chrome.alarms.clear(ALARM_NAMES.AUTO_LOCK); return; diff --git a/extension/content/index.ts b/extension/content/index.ts index 0124ec6c..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 0316a9a4..b99d33e1 100644 --- a/extension/inpage/index.ts +++ b/extension/inpage/index.ts @@ -17,7 +17,7 @@ class NockProvider implements InjectedNockchain { * @param args - Request arguments with method and params */ request(args: RpcRequest): Promise { - const id = Math.random().toString(36).slice(2); + const id = crypto.randomUUID(); // Post message to content script window.postMessage( @@ -26,7 +26,7 @@ class NockProvider implements InjectedNockchain { id, payload: args, }, - '*' + window.location.origin ); // Wait for response with timeout @@ -37,14 +37,27 @@ class NockProvider implements InjectedNockchain { const data = evt.data; // Check if this is our response (must have a reply field, not just the request) - if (data?.target === MESSAGE_TARGET && data.id === id && data.reply !== undefined) { + if ( + evt.source === window && + data && + typeof data === 'object' && + data.target === MESSAGE_TARGET && + data.id === id && + Object.prototype.hasOwnProperty.call(data, 'reply') + ) { window.removeEventListener('message', handler); if (timeoutId) { clearTimeout(timeoutId); } if (data.reply?.error) { - reject(new Error(data.reply.error)); + const message = + typeof data.reply.error === 'string' + ? data.reply.error + : typeof data.reply.error?.message === 'string' + ? data.reply.error.message + : 'Wallet request failed'; + reject(new Error(message)); } else { resolve(data.reply); } diff --git a/extension/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/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..175d4e9b 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,6 +21,7 @@ import type { SignRawTxRequest, } from '../../shared/types'; import type { Screen } from '../store'; +import { isSidePanel } from '../utils/displayContext'; interface UseApprovalDetectionProps { walletAddress: string | null; @@ -33,6 +33,19 @@ interface UseApprovalDetectionProps { navigate: (screen: Screen) => void; } +function getApprovalScreen(type: ApprovalType): Screen { + switch (type) { + case 'connect': + return 'connect-approval'; + case 'transaction': + return 'approve-transaction'; + case 'sign-message': + return 'sign-message'; + case 'sign-raw-tx': + return 'approve-sign-raw-tx'; + } +} + export function useApprovalDetection({ walletAddress, walletLocked, @@ -42,87 +55,168 @@ export function useApprovalDetection({ setPendingSignRawTxRequest, navigate, }: UseApprovalDetectionProps) { + const walletReady = walletAddress !== null; + const pendingSidePanelApproval = useRef<{ requestId: string; type: ApprovalType } | null>(null); + const walletReadyRef = useRef(walletReady); + walletReadyRef.current = walletReady; + + const handleApproval = useCallback( + async (requestId: string, type: ApprovalType) => { + const targetScreen = getApprovalScreen(type); + const lockedScreen: Screen = 'locked'; + + if (type === 'connect') { + const request = await send(INTERNAL_METHODS.GET_PENDING_CONNECTION, [ + requestId, + ]); + if (request && !('error' in request)) { + setPendingConnectRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + return; + } + + if (type === 'transaction') { + const request = await send(INTERNAL_METHODS.GET_PENDING_TRANSACTION, [ + requestId, + ]); + if (request && !('error' in request)) { + setPendingTransactionRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + return; + } + + if (type === 'sign-message') { + const request = await send(INTERNAL_METHODS.GET_PENDING_SIGN_REQUEST, [ + requestId, + ]); + if (request && !('error' in request)) { + setPendingSignRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + return; + } + + const request = await send( + INTERNAL_METHODS.GET_PENDING_SIGN_RAW_TX_REQUEST, + [requestId] + ); + if (request && !('error' in request)) { + setPendingSignRawTxRequest(request); + navigate(walletLocked ? lockedScreen : targetScreen); + } + }, + [ + walletLocked, + setPendingConnectRequest, + setPendingTransactionRequest, + setPendingSignRequest, + setPendingSignRawTxRequest, + navigate, + ] + ); + + const handleApprovalRef = useRef(handleApproval); + handleApprovalRef.current = handleApproval; + + // Side panel: listen for approval messages immediately (may arrive before wallet init) useEffect(() => { - // Wait for wallet state to be initialized - if (walletAddress === null) return; + if (!isSidePanel()) return; - const hash = window.location.hash.slice(1); // Remove '#' + 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); + } else { + pendingSidePanelApproval.current = { + requestId: message.requestId, + type: message.approvalType, + }; + } + } + }; - if (hash.startsWith(APPROVAL_CONSTANTS.CONNECT_HASH_PREFIX)) { - const requestId = hash.replace(APPROVAL_CONSTANTS.CONNECT_HASH_PREFIX, ''); + chrome.runtime.onMessage.addListener(handleRuntimeMessage); + return () => chrome.runtime.onMessage.removeListener(handleRuntimeMessage); + }, []); - // 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'); - } + // Side panel: process queued or pending approvals once wallet is ready + useEffect(() => { + if (!isSidePanel() || !walletReady) return; + + const fetchPendingApproval = () => { + send<{ requestId: string; approvalType: ApprovalType } | null>( + INTERNAL_METHODS.GET_PENDING_APPROVAL + ) + .then(pending => { + if (pending?.requestId && pending.approvalType) { + void handleApproval(pending.requestId, pending.approvalType).catch(console.error); } }) .catch(console.error); - } else if (hash.startsWith(APPROVAL_CONSTANTS.TRANSACTION_HASH_PREFIX)) { - const requestId = hash.replace(APPROVAL_CONSTANTS.TRANSACTION_HASH_PREFIX, ''); + }; + + const queued = pendingSidePanelApproval.current; + if (queued) { + pendingSidePanelApproval.current = null; + void handleApproval(queued.requestId, queued.type).catch(console.error); + return; + } - // 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'); - } + fetchPendingApproval(); + }, [walletReady, handleApproval]); + + // 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; + + send<{ requestId: string; approvalType: ApprovalType } | null>( + INTERNAL_METHODS.GET_PENDING_APPROVAL + ) + .then(pending => { + if (pending?.requestId && pending.approvalType) { + void handleApproval(pending.requestId, pending.approvalType).catch(console.error); } }) .catch(console.error); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [walletReady, handleApproval]); + + // Popup windows: route via URL hash + useEffect(() => { + 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, ''); + 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, ''); + 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..63f0bd6f 100644 --- a/extension/popup/screens/SendScreen.tsx +++ b/extension/popup/screens/SendScreen.tsx @@ -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..bd6bc715 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, + transactionId, + totalFee, + accountAddress, + } = pendingSignRawTxRequest; + const signingAccount = wallet.accounts.find(account => account.address === accountAddress); async function handleDecline() { await send(INTERNAL_METHODS.REJECT_SIGN_RAW_TX, [id]); 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 ID, outputs, and fee below come from the exact transaction, but the input values are not verified. Only continue if you trust the requesting site.`} +
+
-
- {notes.map((note: any, index: number) => ( - - ))} +
+

+ {transactionId} +

+ {/* Verified transaction inputs */} + {inputsVerified && ( +
+ +
+ {inputs.map((note: any, index: number) => ( + + ))} +
+
+ )} + + {signError && ( +
+ {signError} +
+ )} + {/* Raw Transaction Outputs */} {outputs && outputs.length > 0 && (
@@ -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..12cd786a 100644 --- a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +++ b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useStore } from '../../store'; import { ChevronRightIcon } from '../../components/icons/ChevronRightIcon'; import { AccountIcon } from '../../components/AccountIcon'; @@ -5,36 +6,67 @@ import { SiteIcon } from '../../components/SiteIcon'; import { truncateAddress } from '../../utils/format'; import { send } from '../../utils/messaging'; import { INTERNAL_METHODS, NOCK_TO_NICKS } from '../../../shared/constants'; -import { formatNock, formatNick } from '../../../shared/currency'; +import { + formatNicks, + formatNicksAsNock, + formatNock, + nicksToBigInt, +} from '../../../shared/currency'; import { useAutoRejectOnClose } from '../../hooks/useAutoRejectOnClose'; +import { closeAfterApproval } from '../../utils/displayContext'; export function TransactionApprovalScreen() { const { navigate, pendingTransactionRequest, setPendingTransactionRequest, wallet } = useStore(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [approvalError, setApprovalError] = useState(''); + + useAutoRejectOnClose(pendingTransactionRequest?.id ?? null, INTERNAL_METHODS.REJECT_TRANSACTION); + + useEffect(() => { + if (!pendingTransactionRequest) { + navigate('home'); + } + }, [navigate, pendingTransactionRequest]); if (!pendingTransactionRequest) { - navigate('home'); return null; } - const { id, origin, to, amount } = pendingTransactionRequest; + const { id, origin, to, amount, accountAddress } = pendingTransactionRequest; const fee = pendingTransactionRequest.fee; - const amountNum = Number(amount); - const feeNum = Number(fee); - const totalNum = amountNum + feeNum; + const totalNicks = nicksToBigInt(amount) + nicksToBigInt(fee); + const totalNicksString = totalNicks.toString(); const displayOrigin = origin.includes('://') ? new URL(origin).hostname : origin; - - useAutoRejectOnClose(id, INTERNAL_METHODS.REJECT_TRANSACTION); + const signingAccount = wallet.accounts.find(account => account.address === accountAddress); + const signingAccountBalance = wallet.accountSpendableBalances[accountAddress] ?? 0; async function handleReject() { await send(INTERNAL_METHODS.REJECT_TRANSACTION, [id]); 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 +77,7 @@ export function TransactionApprovalScreen() { return (
-
+
{/* Header */}

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

- - {truncateAddress(wallet.currentAccount?.address)} - + {truncateAddress(accountAddress)}
@@ -122,9 +148,9 @@ export function TransactionApprovalScreen() {
Network fee
-
{formatNock(feeNum / NOCK_TO_NICKS)} NOCK
+
{formatNicksAsNock(fee)} NOCK
- {formatNick(feeNum)} nicks + {formatNicks(fee)} nicks
@@ -132,9 +158,9 @@ export function TransactionApprovalScreen() {
Total
-
{formatNock(totalNum / NOCK_TO_NICKS)} NOCK
+
{formatNicksAsNock(totalNicksString)} NOCK
- {formatNick(totalNum)} nicks + {formatNicks(totalNicksString)} nicks
@@ -142,8 +168,22 @@ export function TransactionApprovalScreen() { {/* Balance After */}
- Balance after: {formatNock(wallet.spendableBalance - totalNum / NOCK_TO_NICKS)} NOCK + Balance after:{' '} + {formatNock(signingAccountBalance - Number(totalNicks) / NOCK_TO_NICKS)} NOCK
+ + {approvalError && ( +
+ {approvalError} +
+ )}
@@ -153,11 +193,21 @@ export function TransactionApprovalScreen() { className="px-4 py-2.5 shrink-0 flex gap-3" style={{ borderTop: `1px solid ${divider}` }} > - -
diff --git a/extension/popup/screens/onboarding/BackupScreen.tsx b/extension/popup/screens/onboarding/BackupScreen.tsx index 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.