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

Display mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Sign Raw Transaction + Review Transaction

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

+ {transactionId} +

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

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

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

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

- {transactionId} + {signingIntentId}

diff --git a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx index 7d11ae0a..b85da846 100644 --- a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +++ b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx @@ -175,6 +175,13 @@ export function TransactionApprovalScreen() {
+ {isFeeEstimated && ( +
+ The final network fee is calculated when the transaction is built and may differ + from this estimate. +
+ )} + {/* Balance After */}
{isFeeEstimated ? 'Estimated balance after' : 'Balance after'}:{' '} diff --git a/extension/shared/pending-approvals-session.test.ts b/extension/shared/pending-approvals-session.test.ts index 9ed3035d..40014460 100644 --- a/extension/shared/pending-approvals-session.test.ts +++ b/extension/shared/pending-approvals-session.test.ts @@ -48,6 +48,7 @@ function rawRequest(id: string, timestamp = ACTIVE_TIMESTAMP): SignRawTxRequest inputsVerified: false, inputCount: 1, transactionId: 'tx-id', + signingIntentId: 'intent-id', totalFee: '1' as SignRawTxRequest['totalFee'], reviewBlockHeight: 1, }; diff --git a/extension/shared/transaction-fee.test.ts b/extension/shared/transaction-fee.test.ts index 89848020..84814aa7 100644 --- a/extension/shared/transaction-fee.test.ts +++ b/extension/shared/transaction-fee.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { Nicks } from '@nockbox/iris-sdk/wasm'; -import { resolveBuiltTransactionAmounts, resolveTransactionFeeForBuild } from './transaction-fee'; +import { + buildWithAdvisoryFeeRetry, + resolveBuiltInputSelection, + resolveBuiltTransactionAmounts, + resolveTransactionFeeForBuild, +} from './transaction-fee'; const nicks = (value: string) => value as Nicks; @@ -36,4 +41,94 @@ describe('transaction fee contract', () => { expectedChange: 0, }); }); + + it('retries an underfunded advisory build once with the full local note set', async () => { + const attempts: number[][] = []; + const reserved: number[][] = []; + const result = await buildWithAdvisoryFeeRetry({ + initialCandidates: [1], + retryCandidates: [1, 2, 3], + allowRetry: true, + beforeRetry: async candidates => { + reserved.push([...candidates]); + }, + build: async candidates => { + attempts.push([...candidates]); + if (candidates.length === 1) { + throw new Error('Insufficient funds'); + } + return 'built'; + }, + }); + + expect(result).toEqual({ result: 'built', candidates: [1, 2, 3], retried: true }); + expect(attempts).toEqual([[1], [1, 2, 3]]); + expect(reserved).toEqual([[1, 2, 3]]); + }); + + it('never retries an explicit-fee build', async () => { + const build = async () => { + throw new Error('Insufficient fee for transaction'); + }; + + await expect( + buildWithAdvisoryFeeRetry({ + initialCandidates: [1], + retryCandidates: [1, 2], + allowRetry: false, + build, + }) + ).rejects.toThrow('Insufficient fee'); + }); + + it('fails after one advisory retry when all local notes are still insufficient', async () => { + let attempts = 0; + await expect( + buildWithAdvisoryFeeRetry({ + initialCandidates: [1], + retryCandidates: [1, 2], + allowRetry: true, + build: async () => { + attempts += 1; + throw new Error('Insufficient funds'); + }, + }) + ).rejects.toThrow('Insufficient funds'); + expect(attempts).toBe(2); + }); + + it('does not retry unrelated failures', async () => { + let attempts = 0; + await expect( + buildWithAdvisoryFeeRetry({ + initialCandidates: [1], + retryCandidates: [1, 2], + allowRetry: true, + build: async () => { + attempts += 1; + throw new Error('Invalid recipient'); + }, + }) + ).rejects.toThrow('Invalid recipient'); + expect(attempts).toBe(1); + }); + + it('reconciles reserved candidates to the inputs actually built by WASM', () => { + expect( + resolveBuiltInputSelection( + [ + { noteId: 'a', assets: 7_000 }, + { noteId: 'b', assets: 3_000 }, + { noteId: 'unused', assets: 1_000 }, + ], + ['a', 'b'] + ) + ).toEqual({ inputNoteIds: ['a', 'b'], selectedTotal: 10_000 }); + }); + + it('rejects a built input that was not locally reserved', () => { + expect(() => resolveBuiltInputSelection([{ noteId: 'a', assets: 7_000 }], ['foreign'])).toThrow( + 'unreserved input' + ); + }); }); diff --git a/extension/shared/transaction-fee.ts b/extension/shared/transaction-fee.ts index c4e109fd..40d737da 100644 --- a/extension/shared/transaction-fee.ts +++ b/extension/shared/transaction-fee.ts @@ -18,6 +18,84 @@ export function resolveTransactionFeeForBuild( return feeEstimated ? { feeSelectionHint: displayedFee } : { fee: displayedFee }; } +function isRetryableAdvisoryFeeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /insufficient (?:fee|funds)/i.test(message); +} + +export type AdvisoryFeeBuildResult = { + result: TResult; + candidates: readonly TCandidate[]; + retried: boolean; +}; + +/** + * Retry an advisory-fee build once with a larger candidate set. Exact-fee + * requests and unrelated errors are never retried. + */ +export async function buildWithAdvisoryFeeRetry(options: { + initialCandidates: readonly TCandidate[]; + retryCandidates: readonly TCandidate[]; + allowRetry: boolean; + beforeRetry?: (candidates: readonly TCandidate[]) => Promise; + build: (candidates: readonly TCandidate[]) => Promise; +}): Promise> { + try { + return { + result: await options.build(options.initialCandidates), + candidates: options.initialCandidates, + retried: false, + }; + } catch (error) { + if ( + !options.allowRetry || + options.retryCandidates.length <= options.initialCandidates.length || + !isRetryableAdvisoryFeeError(error) + ) { + throw error; + } + + await options.beforeRetry?.(options.retryCandidates); + return { + result: await options.build(options.retryCandidates), + candidates: options.retryCandidates, + retried: true, + }; + } +} + +type BuiltInputCandidate = { + noteId: string; + assets: number; +}; + +/** Resolve the exact locally-owned inputs selected by the built transaction. */ +export function resolveBuiltInputSelection( + candidates: readonly TCandidate[], + builtInputNoteIds: readonly string[] +): { inputNoteIds: string[]; selectedTotal: number } { + const candidatesById = new Map(candidates.map(candidate => [candidate.noteId, candidate])); + const uniqueInputIds = [...new Set(builtInputNoteIds)]; + if (uniqueInputIds.length !== builtInputNoteIds.length || uniqueInputIds.length === 0) { + throw new Error('Built transaction contains invalid input notes'); + } + + let selectedTotal = 0; + for (const noteId of uniqueInputIds) { + const candidate = candidatesById.get(noteId); + if (!candidate) { + throw new Error('Built transaction contains an unreserved input note'); + } + selectedTotal += candidate.assets; + } + + if (!Number.isSafeInteger(selectedTotal) || selectedTotal < 0) { + throw new Error('Built transaction input value exceeds the supported Nicks range'); + } + + return { inputNoteIds: uniqueInputIds, selectedTotal }; +} + /** Reconcile persisted transaction amounts with the fee actually chosen by WASM. */ export function resolveBuiltTransactionAmounts( selectedTotal: number, diff --git a/extension/shared/types.ts b/extension/shared/types.ts index 8956a38f..fab9b339 100644 --- a/extension/shared/types.ts +++ b/extension/shared/types.ts @@ -188,6 +188,8 @@ export interface SignRawTxRequest { inputCount: number; /** Canonical ID calculated by WASM from rawTx at intake. */ transactionId: string; + /** Stable hash of the witnessless spends that the user is approving. */ + signingIntentId: string; /** Total fee calculated by WASM from rawTx, in nicks. */ totalFee: Nicks; /** Account block height used to derive outputs and transaction-engine settings. */ diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index 2ce982ce..2dd576e7 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -74,7 +74,11 @@ import { import { buildBridgeTransaction, validateBridgeTransaction } from '@nockbox/iris-sdk'; import { BRIDGE_CONFIG } from './bridge-config'; import { rewriteInsufficientFeeErrorToDecimalNock } from './currency'; -import { resolveBuiltTransactionAmounts } from './transaction-fee'; +import { + buildWithAdvisoryFeeRetry, + resolveBuiltInputSelection, + resolveBuiltTransactionAmounts, +} from './transaction-fee'; type SendTransactionV2Options = { /** Advisory fee for note selection only. WASM still calculates the actual fee. */ @@ -3536,8 +3540,6 @@ export class Vault { return { error: 'No available UTXOs.' }; } - const totalAvailable = availableStoredNotes.reduce((sum, n) => sum + n.assets, 0); - // 2. Choose enough notes using the exact override, an advisory estimate, or the // legacy fallback. Only `fee` is forwarded to WASM as an exact fee override. const amountNum = Number(amount); @@ -3580,8 +3582,6 @@ export class Vault { } selectedNoteIds = selectedStoredNotes.map(n => n.noteId); - const selectedTotal = selectedStoredNotes.reduce((sum, n) => sum + n.assets, 0); - // 4. Mark notes as in_flight BEFORE building transaction await this.markNotesInFlight(currentAccount.address, selectedNoteIds, walletTxId); @@ -3603,44 +3603,94 @@ export class Vault { }; await this.addWalletTransaction(walletTx); - // 6. Convert stored notes to transaction builder format - const sortedStoredNotes = [...selectedStoredNotes].sort((a, b) => b.assets - a.assets); - const txBuilderNotes = sortedStoredNotes.map(convertStoredNoteForTxBuilder); - const endpoint = await getEffectiveRpcEndpoint(); const rpcClient = createBrowserClient(endpoint); // For sendMax: set refundPKH = recipient so all funds go to recipient (sweep) const refundAddress = sendMax ? to : undefined; - const constructedTx = await buildMultiNotePayment( - txBuilderNotes, - to, - amount, - accountKey.publicKey, - privateKey, - fee, - refundAddress, - blockHeight + // 6. Build with the approval estimate as a selection hint. If WASM's actual + // fee needs more value, reserve the remaining locally-available notes and + // retry once. Explicit-fee requests never take this path. + const allowAdvisoryRetry = + !sendMax && fee === undefined && options.feeSelectionHint !== undefined; + const buildAttempt = await buildWithAdvisoryFeeRetry({ + initialCandidates: selectedStoredNotes, + retryCandidates: allowAdvisoryRetry ? availableStoredNotes : selectedStoredNotes, + allowRetry: allowAdvisoryRetry, + beforeRetry: async retryCandidates => { + const retryNoteIds = retryCandidates.map(note => note.noteId); + const initiallyReserved = new Set(selectedNoteIds); + const additionalNoteIds = retryNoteIds.filter( + noteId => !initiallyReserved.has(noteId) + ); + + // Assign before awaiting so the outer failure path releases every note + // even if persistence fails after partially reserving the retry set. + selectedNoteIds = retryNoteIds; + if (additionalNoteIds.length > 0) { + await this.markNotesInFlight(currentAccount.address, additionalNoteIds, walletTxId); + } + }, + build: async candidates => { + const txBuilderNotes = [...candidates] + .sort((a, b) => b.assets - a.assets) + .map(convertStoredNoteForTxBuilder); + return await buildMultiNotePayment( + txBuilderNotes, + to, + amount, + accountKey.publicKey, + privateKey, + fee, + refundAddress, + blockHeight + ); + }, + }); + const constructedTx = buildAttempt.result; + + const builtRawTx = wasm.nockchainTxToRawTx(constructedTx.nockchainTx); + const builtInputNoteIds = wasm + .rawTxInputNames(builtRawTx) + .map(name => generateNoteId(String(name.first), String(name.last))); + const builtSelection = resolveBuiltInputSelection( + buildAttempt.candidates, + builtInputNoteIds + ); + + // Retry candidates are reservations, not necessarily transaction inputs. + // Release anything WASM did not select before broadcasting. + const builtInputSet = new Set(builtSelection.inputNoteIds); + const unusedReservedNoteIds = selectedNoteIds.filter( + noteId => !builtInputSet.has(noteId) ); + if (unusedReservedNoteIds.length > 0) { + await this.releaseInFlightNotes(currentAccount.address, unusedReservedNoteIds); + } + selectedNoteIds = builtSelection.inputNoteIds; const actualAmounts = resolveBuiltTransactionAmounts( - selectedTotal, + builtSelection.selectedTotal, amount, constructedTx.feeUsed, sendMax ); // 7. Broadcast transaction + walletTx.inputNoteIds = selectedNoteIds; + walletTx.fee = actualAmounts.fee; + walletTx.expectedChange = actualAmounts.expectedChange; await this.updateWalletTransaction(currentAccount.address, walletTxId, { status: 'broadcast_pending', + inputNoteIds: selectedNoteIds, + fee: actualAmounts.fee, + expectedChange: actualAmounts.expectedChange, }); const protobufTx = nockchainTxToProtobuf(constructedTx.nockchainTx); await rpcClient.sendTransaction(protobufTx); // 8. Update tx status to broadcasted - walletTx.fee = actualAmounts.fee; - walletTx.expectedChange = actualAmounts.expectedChange; walletTx.txHash = constructedTx.txId; walletTx.trackingTxId = constructedTx.txId; walletTx.status = 'broadcasted_unconfirmed'; @@ -4212,6 +4262,7 @@ export class Vault { */ async describeRawTxForApproval(rawTx: wasm.RawTx): Promise<{ transactionId: string; + signingIntentId: string; totalFee: Nicks; blockHeight: number; accountAddress: string; @@ -4226,6 +4277,9 @@ export class Vault { await initWasmModules(); assertNativeRawTx(rawTx); + if (!guard.isRawTxV1(rawTx)) { + throw new Error('Only v1 raw transactions are supported'); + } const currentAccount = this.getCurrentAccount(); if (!currentAccount) { @@ -4254,9 +4308,19 @@ export class Vault { const outputs = wasm .rawTxOutputs(rawTx, blockHeight, settings) .map(output => wasm.noteToProtobuf(output)); + const intentBuilder = wasm.TxBuilder.fromRawTx(rawTx, settings); + let signingIntentId: string; + try { + // Rebuilding splits witness data from spends. Hashing those witnessless + // spends produces an intent ID that remains stable as signatures are added. + signingIntentId = String(wasm.spendsV1Hash(intentBuilder.build().spends)); + } finally { + intentBuilder.free(); + } return { transactionId: String(wasm.rawTxId(rawTx)), + signingIntentId, totalFee: String(wasm.rawTxTotalFees(rawTx)) as Nicks, blockHeight, accountAddress: currentAccount.address, From d7789dafa86fce0b1516522338a7e3ce1ef1af14 Mon Sep 17 00:00:00 2001 From: thomasurbit <99678438+thomasurbit@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:32:08 +0200 Subject: [PATCH 20/21] feat: harden exact transaction approval lifecycle --- extension/background/index.ts | 983 ++++++-- .../shared/approved-origins-state.test.ts | 62 + extension/shared/approved-origins-state.ts | 101 + .../shared/build-request-admission.test.ts | 17 + extension/shared/build-request-admission.ts | 24 + extension/shared/constants.ts | 8 +- .../lifecycle-transition-coordinator.test.ts | 61 + .../lifecycle-transition-coordinator.ts | 30 + extension/shared/pending-approval-state.ts | 10 + .../shared/pending-approvals-session.test.ts | 142 +- extension/shared/pending-approvals-session.ts | 60 + extension/shared/rpc-config-context.test.ts | 125 + extension/shared/rpc-config.ts | 135 +- extension/shared/rpc-methods.test.ts | 10 +- .../shared/serialized-task-queue.test.ts | 41 + extension/shared/serialized-task-queue.ts | 18 + extension/shared/side-panel.test.ts | 5 + extension/shared/transaction-builder.ts | 268 ++- extension/shared/transaction-fee.test.ts | 50 +- extension/shared/transaction-fee.ts | 28 + extension/shared/types.ts | 66 +- extension/shared/utxo-utils.test.ts | 244 ++ extension/shared/utxo-utils.ts | 199 +- extension/shared/vault-lifecycle.test.ts | 238 ++ .../shared/vault-sync-provenance.test.ts | 281 +++ extension/shared/vault.ts | 2125 +++++++++++++---- tests/transaction-intent-wasm.test.mjs | 96 + vitest.config.ts | 2 +- 28 files changed, 4641 insertions(+), 788 deletions(-) create mode 100644 extension/shared/approved-origins-state.test.ts create mode 100644 extension/shared/approved-origins-state.ts create mode 100644 extension/shared/build-request-admission.test.ts create mode 100644 extension/shared/build-request-admission.ts create mode 100644 extension/shared/lifecycle-transition-coordinator.test.ts create mode 100644 extension/shared/lifecycle-transition-coordinator.ts create mode 100644 extension/shared/rpc-config-context.test.ts create mode 100644 extension/shared/serialized-task-queue.test.ts create mode 100644 extension/shared/serialized-task-queue.ts create mode 100644 extension/shared/utxo-utils.test.ts create mode 100644 extension/shared/vault-lifecycle.test.ts create mode 100644 extension/shared/vault-sync-provenance.test.ts create mode 100644 tests/transaction-intent-wasm.test.mjs diff --git a/extension/background/index.ts b/extension/background/index.ts index d8d6d828..6b3ab5fa 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -55,13 +55,16 @@ import { buildPendingApprovalSessionSnapshot, restorePendingApprovalSessionSnapshot, pendingApprovalOriginMatches, + pendingApprovalPermissionStillValid, pendingApprovalAccountMatches, } from '../shared/pending-approval-state'; import { - persistPendingApprovalSession, + PendingApprovalSessionPersistence, loadPendingApprovalSession, } from '../shared/pending-approvals-session'; -import { resolveTransactionFeeForBuild } from '../shared/transaction-fee'; +import { BuildRequestAdmission } from '../shared/build-request-admission'; +import { LifecycleTransitionCoordinator } from '../shared/lifecycle-transition-coordinator'; +import { ApprovedOriginsState } from '../shared/approved-origins-state'; const vault = new Vault(); let lastActivity = Date.now(); @@ -76,11 +79,10 @@ let requestQueue: Array<{ type: 'connect' | 'transaction' | 'sign-message' | 'sign-raw-tx'; }> = []; // Queued requests -/** - * In-memory cache of approved origins - * Loaded from storage on startup, persisted on changes - */ -let approvedOrigins = new Set(); +const approvedOrigins = new ApprovedOriginsState(async origins => { + await chrome.storage.local.set({ [STORAGE_KEYS.APPROVED_ORIGINS]: origins }); +}); +const pendingApprovalSession = new PendingApprovalSessionPersistence(); /** * Request expiration time (5 minutes) @@ -123,14 +125,48 @@ const utxoSyncInFlight = new Map< const subwalletDiscoveryInFlight = new Set(); const subwalletDiscoveryAfterInitialSync = new Set(); +// Public builds are read-only but hold the account mutex during WASM work. +// Bound them independently of the send path so one dApp cannot starve signing/sync. +const providerBuildAdmission = new BuildRequestAdmission(1); +const walletLifecycle = new LifecycleTransitionCoordinator(); +let activeExplicitLifecycleGeneration: number | null = null; + +function scheduleCurrentAccountNetworkBootstrap(isCurrent: () => boolean): void { + const account = vault.getCurrentAccount(); + if (!account || !isCurrent() || vault.isLocked()) return; + void (async () => { + if (!(await vault.accountNeedsSyncForCurrentNetwork(account.address))) return; + if ( + !isCurrent() || + vault.isLocked() || + vault.getCurrentAccount()?.address !== account.address + ) { + return; + } + const outcome = await syncAccountUTXOsWithDedupe(account.address, account.name, { + skipHistory: true, + }); + if (!outcome.results[account.address]?.success && isCurrent()) { + console.warn( + '[Background] Post-unlock network sync failed:', + outcome.results[account.address]?.error + ); + } + })().catch(error => console.warn('[Background] Post-unlock network sync failed:', error)); +} + async function clearUnlockSessionCache(): Promise { try { - await chrome.storage.session?.remove(SESSION_STORAGE_KEYS.UNLOCK_CACHE); + await removeUnlockSessionCache(); } catch (error) { console.error('[Background] Failed to clear unlock cache:', error); } } +async function removeUnlockSessionCache(): Promise { + await chrome.storage.session?.remove(SESSION_STORAGE_KEYS.UNLOCK_CACHE); +} + async function persistUnlockSession(): Promise { const sessionStorage = chrome.storage.session; if (!sessionStorage || vault.isLocked()) { @@ -153,49 +189,61 @@ async function persistUnlockSession(): Promise { } async function restoreUnlockSession(): Promise { - const sessionStorage = chrome.storage.session; - if (!sessionStorage) { - return; - } - - const stored = await sessionStorage.get([SESSION_STORAGE_KEYS.UNLOCK_CACHE]); - const cached = stored[SESSION_STORAGE_KEYS.UNLOCK_CACHE] as UnlockSessionCache['key'] | undefined; + // Polling messages must not invalidate a password/setup transition merely + // because the Vault intentionally stays locked until its durable work ends. + if (activeExplicitLifecycleGeneration !== null) return; + const lifecycleGeneration = walletLifecycle.begin(); + await walletLifecycle.run(lifecycleGeneration, async isCurrent => { + const sessionStorage = chrome.storage.session; + if (!sessionStorage) { + return; + } - if (!cached || cached.length === 0) { - return; - } + const stored = await sessionStorage.get([SESSION_STORAGE_KEYS.UNLOCK_CACHE]); + const cached = stored[SESSION_STORAGE_KEYS.UNLOCK_CACHE] as + | UnlockSessionCache['key'] + | undefined; - // Respect manual lock - never auto-unlock if user explicitly locked - if (manuallyLocked) { - await clearUnlockSessionCache(); - return; - } + if (!cached || cached.length === 0) { + return; + } - // Respect auto-lock timeout window - if (autoLockMinutes > 0) { - const idleMs = Date.now() - lastActivity; - if (idleMs >= autoLockMinutes * 60_000) { + // Respect manual lock - never auto-unlock if user explicitly locked + if (manuallyLocked) { await clearUnlockSessionCache(); return; } - } - try { - const key = await crypto.subtle.importKey( - 'raw', - new Uint8Array(cached), - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ); - const result = await vault.unlockWithKey(key); - if ('error' in result) { + // Respect auto-lock timeout window + if (autoLockMinutes > 0) { + const idleMs = Date.now() - lastActivity; + if (idleMs >= autoLockMinutes * 60_000) { + await clearUnlockSessionCache(); + return; + } + } + + try { + const key = await crypto.subtle.importKey( + 'raw', + new Uint8Array(cached), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + const result = await vault.unlockWithKey(key); + if ('error' in result) { + await clearUnlockSessionCache(); + return; + } + if (isCurrent()) { + scheduleCurrentAccountNetworkBootstrap(isCurrent); + } + } catch (error) { + console.error('[Background] Failed to restore unlock session:', error); await clearUnlockSessionCache(); } - } catch (error) { - console.error('[Background] Failed to restore unlock session:', error); - await clearUnlockSessionCache(); - } + }); } async function ensureSessionRestored(): Promise { @@ -216,7 +264,9 @@ async function ensureSessionRestored(): Promise { * Load approved origins from storage */ async function loadApprovedOrigins(): Promise { + const generation = approvedOrigins.captureGeneration(); const stored = await chrome.storage.local.get([STORAGE_KEYS.APPROVED_ORIGINS]); + if (!approvedOrigins.isCurrent(generation)) return; const origins = Array.isArray(stored[STORAGE_KEYS.APPROVED_ORIGINS]) ? (stored[STORAGE_KEYS.APPROVED_ORIGINS] as unknown[]) : []; @@ -224,40 +274,34 @@ async function loadApprovedOrigins(): Promise { .map(origin => normalizeWebOrigin(origin)) .filter((origin): origin is string => Boolean(origin)); - approvedOrigins = new Set(normalizedOrigins); + approvedOrigins.replace(normalizedOrigins, generation); // Older versions stored full page URLs. Persist the least-privilege origin form. if ( - origins.length !== approvedOrigins.size || + origins.length !== approvedOrigins.values().size || origins.some(origin => typeof origin !== 'string' || !approvedOrigins.has(origin)) ) { - await saveApprovedOrigins(); + try { + await approvedOrigins.persist(generation); + } catch (error) { + if (!approvedOrigins.isCurrent(generation)) return; + throw error; + } } } -/** - * Save approved origins to storage - */ -async function saveApprovedOrigins(): Promise { - await chrome.storage.local.set({ - [STORAGE_KEYS.APPROVED_ORIGINS]: Array.from(approvedOrigins), - }); -} - /** * Add an origin to the approved list */ -async function approveOrigin(origin: string): Promise { - approvedOrigins.add(origin); - await saveApprovedOrigins(); +async function approveOrigin(origin: string, generation?: number): Promise { + await approvedOrigins.approve(origin, generation); } /** * Remove an origin from the approved list */ async function revokeOrigin(origin: string): Promise { - approvedOrigins.delete(origin); - await saveApprovedOrigins(); + await approvedOrigins.revoke(origin); } /** @@ -325,12 +369,13 @@ async function syncPendingApprovalsSession(): Promise { requestQueue, isRequestExpired ); - await persistPendingApprovalSession(snapshot); + await pendingApprovalSession.persist(snapshot); } async function restorePendingApprovalsSession(): Promise { + const generation = approvedOrigins.captureGeneration(); const snapshot = await loadPendingApprovalSession(); - if (!snapshot) { + if (!snapshot || !approvedOrigins.isCurrent(generation) || pendingApprovalSession.isResetting()) { return; } @@ -359,6 +404,13 @@ async function restorePendingApprovalsSession(): Promise { currentRequestType && (await getDisplayMode()) === DISPLAY_MODES.SIDE_PANEL ) { + if ( + !approvedOrigins.isCurrent(generation) || + pendingApprovalSession.isResetting() || + !pendingRequests.has(currentRequestId) + ) { + return; + } await notifyApprovalPending(currentRequestId, currentRequestType); } } @@ -408,6 +460,15 @@ async function validatePendingApproval( pending: PendingRequest, sendResponse: (response: unknown) => void ): Promise { + const approvalGeneration = approvedOrigins.captureGeneration(); + const approvalIsCurrent = () => + approvedOrigins.isCurrent(approvalGeneration) && pendingRequests.get(requestId) === pending; + + if (!approvalIsCurrent()) { + sendResponse({ error: 'Wallet authorization changed; request the action again' }); + return false; + } + if (isRequestExpired(pending.request.timestamp)) { cancelPendingRequest(requestId, 4003, 'Request expired'); processNextRequest(); @@ -434,13 +495,44 @@ async function validatePendingApproval( } pending.processing = true; + if (!pendingApprovalPermissionStillValid(pending.request, approvedOrigins.values())) { + cancelPendingRequest( + requestId, + 4100, + 'Origin permission was revoked; request the action again' + ); + processNextRequest(); + sendResponse({ error: 'Origin permission was revoked; request the action again' }); + return false; + } + if (!(await isPendingRequesterActive(pending))) { + if (!approvalIsCurrent()) { + sendResponse({ error: 'Wallet authorization changed; request the action again' }); + return false; + } cancelPendingRequest(requestId, 4001, 'Requesting page is no longer active'); processNextRequest(); sendResponse({ error: 'Requesting page is no longer active' }); return false; } + if (!approvalIsCurrent()) { + sendResponse({ error: 'Wallet authorization changed; request the action again' }); + return false; + } + + if (!pendingApprovalPermissionStillValid(pending.request, approvedOrigins.values())) { + cancelPendingRequest( + requestId, + 4100, + 'Origin permission was revoked; request the action again' + ); + processNextRequest(); + sendResponse({ error: 'Origin permission was revoked; request the action again' }); + return false; + } + if (isConnectRequest(pending.request) && !pending.request.accountAddress && !vault.isLocked()) { pending.request.accountAddress = vault.getAddress(); void syncPendingApprovalsSession(); @@ -453,6 +545,11 @@ async function validatePendingApproval( return false; } + if (!approvalIsCurrent()) { + sendResponse({ error: 'Wallet authorization changed; request the action again' }); + return false; + } + return true; } @@ -514,7 +611,7 @@ function isProviderMethod(method: unknown): method is string { method === PROVIDER_METHODS.SEND_TRANSACTION || method === PROVIDER_METHODS.GET_WALLET_INFO || method === PROVIDER_METHODS.SIGN_TX || - method === PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE || + method === PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION || // Legacy v0 API method method === LEGACY_SIGN_RAW_TX_METHOD ); @@ -638,6 +735,86 @@ interface PendingRequest { const pendingRequests = new Map(); +interface ApprovalResetToken { + authorizationGeneration: number; + sessionGeneration: number; +} + +interface ProviderAuthorizationSnapshot { + generation: number; + origin: string; + accountAddress: string; +} + +function clearPendingApprovalMemory(message = 'Wallet reset; request the action again'): void { + const pending = Array.from(pendingRequests.values()); + pendingRequests.clear(); + requestQueue = []; + currentRequestId = null; + currentRequestType = null; + + for (const request of pending) { + try { + request.sendResponse({ error: { code: 4900, message } }); + } catch { + // A service-worker-restored request may no longer have a live responder. + } + } +} + +function beginApprovalStateReset(): ApprovalResetToken { + const token = { + authorizationGeneration: approvedOrigins.beginReset(), + sessionGeneration: pendingApprovalSession.beginReset(), + }; + clearPendingApprovalMemory(); + return token; +} + +function finishApprovalStateReset(token: ApprovalResetToken): void { + // Catch requests that arrived while reset was clearing durable state. Session + // writes stayed blocked during this window, so the final empty memory state + // and the durable null snapshot agree. + clearPendingApprovalMemory(); + pendingApprovalSession.finishReset(token.sessionGeneration); + approvedOrigins.finishReset(token.authorizationGeneration); +} + +function captureProviderAuthorization(origin: string): ProviderAuthorizationSnapshot | null { + const accountAddress = vault.getCurrentAccount()?.address; + if (!accountAddress || vault.isLocked() || !isOriginApproved(origin)) { + return null; + } + return { + generation: approvedOrigins.captureGeneration(), + origin, + accountAddress, + }; +} + +function providerAuthorizationStillValid(snapshot: ProviderAuthorizationSnapshot): boolean { + return ( + approvedOrigins.isCurrent(snapshot.generation) && + isOriginApproved(snapshot.origin) && + !vault.isLocked() && + vault.getCurrentAccount()?.address === snapshot.accountAddress + ); +} + +function pendingApprovalStillCurrent( + requestId: string, + pending: PendingRequest, + generation: number, + accountAddress: string +): boolean { + return ( + approvedOrigins.isCurrent(generation) && + pendingRequests.get(requestId) === pending && + !vault.isLocked() && + vault.getCurrentAccount()?.address === accountAddress + ); +} + /** * Type guard to check if a request is a ConnectRequest */ @@ -789,6 +966,10 @@ async function createPopupWithFallback(opts: any): Promise { * Queues requests if user is currently viewing another request */ async function createApprovalPopup(requestId: string, type: ApprovalType, tabId?: number) { + if (!pendingRequests.has(requestId)) { + return; + } + // If user is currently viewing a different request, queue this one if (currentRequestId !== null && currentRequestId !== requestId) { // Check if already in queue to prevent duplicates @@ -805,9 +986,18 @@ async function createApprovalPopup(requestId: string, type: ApprovalType, tabId? currentRequestType = type; const displayMode = await getDisplayMode(); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } if (displayMode === DISPLAY_MODES.SIDE_PANEL) { - await routeApprovalToSidePanel(requestId, type, tabId); - void syncPendingApprovalsSession(); + await openSidePanelForApproval(tabId); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } + await notifyApprovalPending(requestId, type); + if (pendingRequests.has(requestId) && currentRequestId === requestId) { + void syncPendingApprovalsSession(); + } return; } @@ -827,10 +1017,16 @@ async function createApprovalPopup(requestId: string, type: ApprovalType, tabId? if (approvalWindowId !== null) { try { const existingWindow = await chrome.windows.get(approvalWindowId); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } // Window still exists - update it with new request if (existingWindow.tabs && existingWindow.tabs[0]?.id) { await chrome.tabs.update(existingWindow.tabs[0].id, { url: popupUrl }); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } await chrome.windows.update(approvalWindowId, { focused: true }); return; // Done - reused existing window } else { @@ -846,6 +1042,9 @@ async function createApprovalPopup(requestId: string, type: ApprovalType, tabId? // Prevent race condition: if window is being created, wait and retry if (isCreatingWindow) { await new Promise(resolve => setTimeout(resolve, 100)); + if (!pendingRequests.has(requestId) || currentRequestId !== requestId) { + return; + } return createApprovalPopup(requestId, type, tabId); } @@ -890,12 +1089,16 @@ async function createApprovalPopup(requestId: string, type: ApprovalType, tabId? focused: true, }); - approvalWindowId = newWindow.id || null; + if (pendingRequests.has(requestId) && currentRequestId === requestId) { + approvalWindowId = newWindow.id || null; + } } finally { isCreatingWindow = false; } - void syncPendingApprovalsSession(); + if (pendingRequests.has(requestId) && currentRequestId === requestId) { + void syncPendingApprovalsSession(); + } } /** @@ -931,10 +1134,16 @@ function processNextRequest() { * Emit a wallet event to all tabs * This notifies dApps of wallet state changes (account switches, network changes, etc.) */ -async function emitWalletEvent(eventType: string, data: unknown) { +async function emitWalletEvent( + eventType: string, + data: unknown, + isCurrent: () => boolean = () => true +) { const tabs = await chrome.tabs.query({}); + if (!isCurrent()) return; for (const tab of tabs) { + if (!isCurrent()) return; if (tab.id) { try { await chrome.tabs.sendMessage(tab.id, { @@ -1012,14 +1221,16 @@ function scheduleQueuedSubwalletDiscoveryAfterInitialSync(accountAddress: string async function syncAccountUTXOsWithDedupe( accountAddress: string, - accountName = accountAddress + accountName = accountAddress, + options: { skipHistory?: boolean } = {} ): Promise<{ ok: boolean; results: Record; }> { - let inFlight = utxoSyncInFlight.get(accountAddress); + const syncKey = `${accountAddress}:${options.skipHistory ? 'utxo-only' : 'full'}`; + let inFlight = utxoSyncInFlight.get(syncKey); if (!inFlight) { - inFlight = (async () => { + const created = (async () => { const results: Record = {}; if (vault.isLocked()) { @@ -1031,7 +1242,7 @@ async function syncAccountUTXOsWithDedupe( } try { - await vault.syncAccountUTXOs(accountAddress); + await vault.syncAccountUTXOs(accountAddress, options); results[accountAddress] = { success: true }; scheduleQueuedSubwalletDiscoveryAfterInitialSync(accountAddress); } catch (syncErr) { @@ -1043,10 +1254,13 @@ async function syncAccountUTXOsWithDedupe( } return { ok: true, results }; - })().finally(() => { - utxoSyncInFlight.delete(accountAddress); + })(); + inFlight = created.finally(() => { + if (utxoSyncInFlight.get(syncKey) === inFlight) { + utxoSyncInFlight.delete(syncKey); + } }); - utxoSyncInFlight.set(accountAddress, inFlight); + utxoSyncInFlight.set(syncKey, inFlight); } return inFlight; @@ -1089,8 +1303,6 @@ const initPromise = (async () => { 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 - await applyDisplayMode(await getDisplayMode()); if (autoLockMinutes === 0) { @@ -1165,19 +1377,47 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { maybeOpenSidePanelOnGesture(msg, _sender); (async () => { - await initPromise; - await ensureSessionRestored(); const sourcePayload = ((msg || {}).payload || {}) as IncomingRpcRequest; + const requestedMethod = sourcePayload.method; + const shouldPreemptSessionRestore = + isFromPopup(_sender) && + (requestedMethod === INTERNAL_METHODS.LOCK || + requestedMethod === INTERNAL_METHODS.RESET_WALLET); + let preemptiveLock: + | { + generation: number; + lockPromise: Promise<{ ok: boolean }>; + approvalReset?: ApprovalResetToken; + } + | undefined; + if (shouldPreemptSessionRestore) { + manuallyLocked = true; + const generation = walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + preemptiveLock = { + generation, + lockPromise: vault.lock(), + ...(requestedMethod === INTERNAL_METHODS.RESET_WALLET + ? { approvalReset: beginApprovalStateReset() } + : {}), + }; + } + await initPromise; + if (!preemptiveLock) { + await ensureSessionRestored(); + } let payload: any; try { - payload = await bridgeIncomingProviderPayload(sourcePayload); + payload = preemptiveLock ? sourcePayload : await bridgeIncomingProviderPayload(sourcePayload); } catch (err) { sendResponse(toInvalidParamsError(err)); return; } - const sendBridgedResponse = async (response: unknown): Promise => { + const sendBridgedResponse = (response: unknown): void => { try { - sendResponse(await bridgeOutgoingProviderResponse(sourcePayload, response)); + // Provider mapping is synchronous. Keep mapping + sendResponse in one + // turn so a final authorization guard cannot be invalidated between them. + sendResponse(bridgeOutgoingProviderResponse(sourcePayload, response)); } catch (err) { console.error('[Background] Failed to bridge provider response:', err); sendResponse(toInternalProviderError(err)); @@ -1197,7 +1437,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Only authorized extension-page methods can extend the unlock window. // Page-originated provider traffic is not proof of user activity. - await touchActivity(payload?.method); + if (!preemptiveLock) { + await touchActivity(payload?.method); + } switch (payload?.method) { // Provider methods (called from injected provider via content script) @@ -1246,17 +1488,35 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } + const connectAuthorization = captureProviderAuthorization(connectOrigin); + if (!connectAuthorization) { + await sendBridgedResponse({ error: ERROR_CODES.LOCKED }); + return; + } + try { const connectRpcConfig = await getEffectiveRpcConfig(); - await sendBridgedResponse(buildConnectResponse(vault.getAddress(), connectRpcConfig)); + if (!providerAuthorizationStillValid(connectAuthorization)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + await sendBridgedResponse( + buildConnectResponse(connectAuthorization.accountAddress, connectRpcConfig) + ); } catch (err) { console.error('[Background] Failed to build connect response:', err); - await sendBridgedResponse(toInternalProviderError(err)); + await sendBridgedResponse( + providerAuthorizationStillValid(connectAuthorization) + ? toInternalProviderError(err) + : { error: { code: 4100, message: 'Unauthorized origin' } } + ); return; } // Emit connect event when dApp connects successfully - await emitWalletEvent('connect', { chainId: CHAIN_ID }); + await emitWalletEvent('connect', { chainId: CHAIN_ID }, () => + providerAuthorizationStillValid(connectAuthorization) + ); return; case PROVIDER_METHODS.SIGN_MESSAGE: @@ -1340,6 +1600,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { let review: Awaited>; try { await ensureWasmInitialized(); + // `notes` is an untrusted compatibility sidecar. Approval inputs and + // display data are always recomputed from tx + the encrypted UTXO store. nativeRawTx = wasm.nockchainTxToRawTx(signTxParams.tx); assertNativeRawTx(nativeRawTx); review = await vault.describeRawTxForApproval(nativeRawTx); @@ -1369,6 +1631,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { totalFee: review.totalFee, reviewBlockHeight: review.blockHeight, accountAddress: review.accountAddress, + transactionContext: review.transactionContext, timestamp: Date.now(), }; @@ -1403,6 +1666,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { await sendBridgedResponse({ error: ERROR_CODES.LOCKED }); return; } + if (!vault.canCurrentAccountSignLocally()) { + await sendBridgedResponse({ + error: { code: 4200, message: 'Current account is external and cannot sign locally' }, + }); + return; + } const sendTxParams = payload.params && typeof payload.params === 'object' ? payload.params : {}; const { to, amount, fee } = sendTxParams; @@ -1423,29 +1692,60 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } - // Fee omitted: estimate it now so the approval popup can display it. - // Estimation failure rejects the request up front (better than a popup - // with no fee or a guaranteed-to-fail broadcast). - let displayFeeNicks: Nicks; - if (feeNicks === undefined) { - try { - const sendTxEstimate = await vault.estimateTransactionFee(to, amountNicks); - if ('error' in sendTxEstimate) { - await sendBridgedResponse({ - error: { code: -32603, message: `Fee estimation failed: ${sendTxEstimate.error}` }, - }); - return; - } - displayFeeNicks = String(sendTxEstimate.fee) as Nicks; - } catch (err) { - console.error('[Background] Fee estimation for sendTransaction failed:', err); - await sendBridgedResponse(toInternalProviderError(err)); - return; - } - } else { - displayFeeNicks = feeNicks; + // Build once before approval so the UI reviews the exact fee and input + // intent that will later be signed. This read-only step does not reserve + // notes, write history, or derive private key material. + const releaseSendBuildAdmission = providerBuildAdmission.tryAcquire(sendTxOrigin); + if (!releaseSendBuildAdmission) { + await sendBridgedResponse({ + error: { code: -32005, message: 'Another transaction build is already in progress' }, + }); + return; + } + const sendBuildAccountAddress = vault.getCurrentAccount()?.address; + let builtSendTransaction: Awaited>; + try { + builtSendTransaction = await vault.buildSimpleTransaction(to, amountNicks, feeNicks); + } finally { + releaseSendBuildAdmission(); + } + + // Authorization and account selection can change while WASM is building. + // Recheck before inspecting success/error so a revoked caller cannot + // learn wallet-specific build failure details. + if (!isOriginApproved(sendTxOrigin)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; } - if (BigInt(amountNicks) + BigInt(displayFeeNicks) > BigInt(Number.MAX_SAFE_INTEGER)) { + if ( + vault.isLocked() || + !sendBuildAccountAddress || + vault.getCurrentAccount()?.address !== sendBuildAccountAddress + ) { + await sendBridgedResponse({ + error: { code: 4001, message: 'Selected account changed; request the action again' }, + }); + return; + } + if ('error' in builtSendTransaction) { + await sendBridgedResponse({ + error: { code: -32603, message: builtSendTransaction.error }, + }); + return; + } + if (builtSendTransaction.accountAddress !== sendBuildAccountAddress) { + await sendBridgedResponse({ + error: { code: 4001, message: 'Selected account changed; request the action again' }, + }); + return; + } + + const { transactionContext, ...reviewedTransaction } = builtSendTransaction; + + if ( + BigInt(amountNicks) + BigInt(builtSendTransaction.fee) > + BigInt(Number.MAX_SAFE_INTEGER) + ) { await sendBridgedResponse( toInvalidParamsError(new Error('Amount plus fee exceeds the supported range')) ); @@ -1459,9 +1759,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { origin: sendTxOrigin, to, amount: amountNicks, - fee: displayFeeNicks, - feeEstimated: feeNicks === undefined, - accountAddress: vault.getAddress(), + fee: builtSendTransaction.fee, + accountAddress: builtSendTransaction.accountAddress, + builtTransaction: reviewedTransaction, + transactionContext, timestamp: Date.now(), }; @@ -1497,19 +1798,35 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } + const walletInfoAuthorization = captureProviderAuthorization(getInfoOrigin); + if (!walletInfoAuthorization) { + await sendBridgedResponse({ error: ERROR_CODES.LOCKED }); + return; + } + try { const walletInfoRpcConfig = await getEffectiveRpcConfig(); - await sendBridgedResponse(buildConnectResponse(vault.getAddress(), walletInfoRpcConfig)); + if (!providerAuthorizationStillValid(walletInfoAuthorization)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + await sendBridgedResponse( + buildConnectResponse(walletInfoAuthorization.accountAddress, walletInfoRpcConfig) + ); } catch (err) { console.error('[Background] Failed to build wallet info response:', err); - await sendBridgedResponse(toInternalProviderError(err)); + await sendBridgedResponse( + providerAuthorizationStillValid(walletInfoAuthorization) + ? toInternalProviderError(err) + : { error: { code: 4100, message: 'Unauthorized origin' } } + ); } return; - case PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE: { - // Read-only like GET_WALLET_INFO: approved origin + unlocked vault, no approval popup - const estimateFeeOrigin = senderOrigin!; - if (!isOriginApproved(estimateFeeOrigin)) { + case PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION: { + // Read-only like GET_WALLET_INFO: approved origin + unlocked vault, no approval popup. + const buildSimpleOrigin = senderOrigin!; + if (!isOriginApproved(buildSimpleOrigin)) { await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); return; } @@ -1519,37 +1836,83 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } - const estimateFeeParams = + const buildSimpleParams = payload.params && typeof payload.params === 'object' ? payload.params : {}; - const { to: estimateFeeTo, amount: estimateFeeAmount } = estimateFeeParams; - if (!isNockAddress(estimateFeeTo)) { + const { + to: buildSimpleTo, + amount: buildSimpleAmount, + fee: buildSimpleFee, + } = buildSimpleParams; + if (!isNockAddress(buildSimpleTo)) { await sendBridgedResponse({ error: ERROR_CODES.BAD_ADDRESS }); return; } - let estimateFeeAmountNicks: Nicks; + let buildSimpleAmountNicks: Nicks; + let buildSimpleFeeNicks: Nicks | undefined; try { - estimateFeeAmountNicks = parseNicksParam(estimateFeeAmount, 'amount'); + buildSimpleAmountNicks = parseNicksParam(buildSimpleAmount, 'amount'); + buildSimpleFeeNicks = + buildSimpleFee === undefined || buildSimpleFee === null + ? undefined + : parseNicksParam(buildSimpleFee, 'fee', { allowZero: true }); } catch (err) { await sendBridgedResponse(toInvalidParamsError(err)); return; } + const releaseBuildAdmission = providerBuildAdmission.tryAcquire(buildSimpleOrigin); + if (!releaseBuildAdmission) { + await sendBridgedResponse({ + error: { code: -32005, message: 'Another transaction build is already in progress' }, + }); + return; + } + const buildAccountAddress = vault.getCurrentAccount()?.address; + try { - const estimateFeeResult = await vault.estimateTransactionFee( - estimateFeeTo, - estimateFeeAmountNicks + const buildSimpleResult = await vault.buildSimpleTransaction( + buildSimpleTo, + buildSimpleAmountNicks, + buildSimpleFeeNicks ); - if ('error' in estimateFeeResult) { + + // Recheck before inspecting success/error so revoked callers cannot + // learn wallet-specific build failure details. + if (!isOriginApproved(buildSimpleOrigin)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + if ( + vault.isLocked() || + !buildAccountAddress || + vault.getCurrentAccount()?.address !== buildAccountAddress + ) { + await sendBridgedResponse({ + error: { code: 4001, message: 'Selected account changed; request the action again' }, + }); + return; + } + if ('error' in buildSimpleResult) { await sendBridgedResponse({ - error: { code: -32603, message: estimateFeeResult.error }, + error: { code: -32603, message: buildSimpleResult.error }, }); return; } - // Vault returns a number; the public API uses canonical Nicks (string) - await sendBridgedResponse({ fee: String(estimateFeeResult.fee) as Nicks }); + if (buildSimpleResult.accountAddress !== buildAccountAddress) { + await sendBridgedResponse({ + error: { code: 4001, message: 'Selected account changed; request the action again' }, + }); + return; + } + + const { transactionContext: _transactionContext, ...publicBuildResult } = + buildSimpleResult; + await sendBridgedResponse(publicBuildResult); } catch (err) { - console.error('[Background] Public fee estimation failed:', err); + console.error('[Background] Public unsigned transaction build failed:', err); await sendBridgedResponse(toInternalProviderError(err)); + } finally { + releaseBuildAdmission(); } return; } @@ -1607,76 +1970,197 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; - case INTERNAL_METHODS.UNLOCK: - const unlockResult = await vault.unlock(payload.params?.[0]); // password - sendResponse(unlockResult); - - // Emit connect event when unlock succeeds - if ('ok' in unlockResult && unlockResult.ok) { - // Clear manual lock flag when successfully unlocked - manuallyLocked = false; - await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); - await persistUnlockSession(); - await emitWalletEvent('connect', { chainId: CHAIN_ID }); + case INTERNAL_METHODS.UNLOCK: { + if (activeExplicitLifecycleGeneration !== null) { + sendResponse({ error: 'Another wallet lifecycle action is already in progress' }); + return; + } + const generation = walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + try { + const unlockResult = await walletLifecycle.run(generation, async isCurrent => { + const result = await vault.unlock(payload.params?.[0]); + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + if (!('ok' in result) || !result.ok) return result; + + scheduleCurrentAccountNetworkBootstrap(isCurrent); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; + manuallyLocked = false; + await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; + await persistUnlockSession(); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; + await emitWalletEvent('connect', { chainId: CHAIN_ID }, isCurrent); + return result; + }); + sendResponse(unlockResult ?? { error: ERROR_CODES.LOCKED }); + } finally { + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; + } } return; + } - case INTERNAL_METHODS.LOCK: - // Set manual lock flag - user explicitly locked, don't auto-unlock + case INTERNAL_METHODS.LOCK: { manuallyLocked = true; - await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: true }); - await vault.lock(); - await clearUnlockSessionCache(); - sendResponse({ ok: true }); - - // Emit disconnect event when wallet locks - await emitWalletEvent('disconnect', { code: 1013, message: 'Wallet locked' }); + const generation = preemptiveLock?.generation ?? walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + // The synchronous prefix invalidates signing immediately; durable and + // externally visible effects remain ordered behind older transitions. + const lockPromise = preemptiveLock?.lockPromise ?? vault.lock(); + try { + const lockResult = await walletLifecycle.run(generation, async isCurrent => { + await lockPromise; + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + subwalletDiscoveryAfterInitialSync.clear(); + await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: true }); + await clearUnlockSessionCache(); + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + await emitWalletEvent( + 'disconnect', + { code: 1013, message: 'Wallet locked' }, + isCurrent + ); + return { ok: true }; + }); + sendResponse(lockResult ?? { error: ERROR_CODES.LOCKED }); + } finally { + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; + } + } return; + } - case INTERNAL_METHODS.RESET_WALLET: - // Reset the wallet completely - clears all data - await vault.reset(); - await clearUnlockSessionCache(); - subwalletDiscoveryAfterInitialSync.clear(); - subwalletDiscoveryInFlight.clear(); - manuallyLocked = false; - sendResponse({ ok: true }); + case INTERNAL_METHODS.RESET_WALLET: { + manuallyLocked = true; + const generation = preemptiveLock?.generation ?? walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + const lockPromise = preemptiveLock?.lockPromise ?? vault.lock(); + const approvalReset = preemptiveLock?.approvalReset ?? beginApprovalStateReset(); + let localStorageResetCompleted = false; + let pendingSessionResetCompleted = false; + let unlockSessionResetCompleted = false; + let resetResult: { ok: true } | { error: string } | undefined; + let resetFailure: unknown; + try { + resetResult = await walletLifecycle.run(generation, async isCurrent => { + await lockPromise; + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + await approvedOrigins.runReset(approvalReset.authorizationGeneration, async () => { + await vault.reset(); + }); + localStorageResetCompleted = true; + pendingSessionResetCompleted = await pendingApprovalSession.clearForReset( + approvalReset.sessionGeneration + ); + if (!pendingSessionResetCompleted) { + throw new Error('Approval-session reset was superseded'); + } + await removeUnlockSessionCache(); + unlockSessionResetCompleted = true; + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + subwalletDiscoveryAfterInitialSync.clear(); + subwalletDiscoveryInFlight.clear(); + utxoSyncInFlight.clear(); + manuallyLocked = false; + await emitWalletEvent('disconnect', { code: 1013, message: 'Wallet reset' }, isCurrent); + return { ok: true }; + }); + } catch (error) { + resetFailure = error; + } finally { + if (!localStorageResetCompleted) { + // Even if the broader vault reset fails or is superseded, never + // leave the old origin permission behind after reset was requested. + try { + await approvedOrigins.runReset(approvalReset.authorizationGeneration, async () => { + await chrome.storage.local.remove(STORAGE_KEYS.APPROVED_ORIGINS); + }); + localStorageResetCompleted = true; + } catch (error) { + resetFailure ??= error; + } + } + if (!pendingSessionResetCompleted) { + try { + pendingSessionResetCompleted = await pendingApprovalSession.clearForReset( + approvalReset.sessionGeneration + ); + if (!pendingSessionResetCompleted) { + throw new Error('Approval-session reset was superseded'); + } + } catch (error) { + resetFailure ??= error; + } + } + if (!unlockSessionResetCompleted) { + try { + await removeUnlockSessionCache(); + unlockSessionResetCompleted = true; + } catch (error) { + resetFailure ??= error; + } + } - // Emit disconnect event - await emitWalletEvent('disconnect', { code: 1013, message: 'Wallet reset' }); + clearPendingApprovalMemory(); + if ( + localStorageResetCompleted && + pendingSessionResetCompleted && + unlockSessionResetCompleted + ) { + finishApprovalStateReset(approvalReset); + } + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; + } + } + if (resetFailure) { + console.error('[Background] Wallet reset failed:', resetFailure); + sendResponse({ + error: resetFailure instanceof Error ? resetFailure.message : 'Wallet reset failed', + }); + } else { + sendResponse(resetResult ?? { error: ERROR_CODES.LOCKED }); + } return; + } - case INTERNAL_METHODS.SETUP: - // params: password, mnemonic (optional). If no mnemonic, generates one automatically. - const setupResult = await vault.setup(payload.params?.[0], payload.params?.[1]); - - if ('ok' in setupResult && setupResult.ok) { - manuallyLocked = false; + case INTERNAL_METHODS.SETUP: { + if (activeExplicitLifecycleGeneration !== null) { + sendResponse({ error: 'Another wallet lifecycle action is already in progress' }); + return; } - // Respond as soon as setup completes — do not await session/local persistence or discovery. - sendResponse(setupResult); + const generation = walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + try { + const setupResult = await walletLifecycle.run(generation, async isCurrent => { + const result = await vault.setup(payload.params?.[0], payload.params?.[1]); + if (!isCurrent()) return { error: ERROR_CODES.LOCKED }; + if (!('ok' in result) || !result.ok) return result; - if ('ok' in setupResult && setupResult.ok) { - void (async () => { + manuallyLocked = false; await chrome.storage.local.set({ [STORAGE_KEYS.MANUALLY_LOCKED]: false }); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; await persistUnlockSession(); - })().catch(err => console.error('[Background] Post-SETUP persistence failed:', err)); - // Only scan for existing on-chain sub-wallets when importing a phrase (not brand-new generation). - const importedExistingPhrase = payload.params?.[2] === true; - if (importedExistingPhrase) { - const firstSeedId = vault.getSeedSources()[0]?.id; - if (firstSeedId) { - queueSubwalletDiscoveryAfterInitialSync(firstSeedId); - } - const currentAccount = vault.getCurrentAccount(); - if (currentAccount) { - void syncAccountUTXOsWithDedupe(currentAccount.address, currentAccount.name).catch( - err => console.warn('[Background] Initial imported wallet sync failed:', err) - ); + if (!isCurrent() || vault.isLocked()) return { error: ERROR_CODES.LOCKED }; + + if (payload.params?.[2] === true) { + const firstSeedId = vault.getSeedSources()[0]?.id; + if (firstSeedId) queueSubwalletDiscoveryAfterInitialSync(firstSeedId); } + scheduleCurrentAccountNetworkBootstrap(isCurrent); + return isCurrent() && !vault.isLocked() ? result : { error: ERROR_CODES.LOCKED }; + }); + sendResponse(setupResult ?? { error: ERROR_CODES.LOCKED }); + } finally { + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; } } return; + } case INTERNAL_METHODS.GET_STATE: // Initialize vault state from storage before checking status @@ -2071,22 +2555,31 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } try { - const feeBuildOptions = resolveTransactionFeeForBuild( - txRequest.fee, - txRequest.feeEstimated - ); - const v2Result = await vault.sendTransactionV2( - txRequest.to, - txRequest.amount, - feeBuildOptions.fee, - false, - undefined, - 'provider_send', - { - accountAddress: txRequest.accountAddress, - feeSelectionHint: feeBuildOptions.feeSelectionHint, + let v2Result; + if (txRequest.builtTransaction) { + if (!txRequest.transactionContext) { + throw new Error('Transaction context unavailable; request the action again'); } - ); + if ( + txRequest.builtTransaction.accountAddress !== txRequest.accountAddress || + txRequest.builtTransaction.to !== txRequest.to || + txRequest.builtTransaction.amount !== txRequest.amount || + txRequest.builtTransaction.fee !== txRequest.fee + ) { + throw new Error('Transaction review changed after approval was requested'); + } + v2Result = await vault.sendBuiltSimpleTransaction( + txRequest.builtTransaction, + txRequest.to, + txRequest.transactionContext, + 'provider_send', + () => isOriginApproved(txRequest.origin) + ); + } else { + // A restored pre-snapshot provider approval cannot be bound to + // exact inputs or re-authorized at the final broadcast boundary. + throw new Error('Transaction review is outdated; request the transaction again'); + } if ('error' in v2Result) { throw new Error(v2Result.error); @@ -2151,6 +2644,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { [signRequest.message], signRequest.accountAddress ); + if (!isOriginApproved(signRequest.origin)) { + throw new Error('Request authorization changed after approval'); + } approveSignPending.sendResponse(signMessageResponse); cancelPendingRequest(approveSignId); processNextRequest(); @@ -2202,11 +2698,16 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { try { assertNativeRawTx(signRawTxRequest.rawTx); const currentReview = await vault.describeRawTxForApproval(signRawTxRequest.rawTx); + if (!signRawTxRequest.transactionContext) { + throw new Error('Transaction review context is unavailable; request approval again'); + } if ( currentReview.transactionId !== signRawTxRequest.transactionId || currentReview.signingIntentId !== signRawTxRequest.signingIntentId || currentReview.blockHeight !== signRawTxRequest.reviewBlockHeight || currentReview.accountAddress !== signRawTxRequest.accountAddress || + currentReview.transactionContext.fingerprint !== + signRawTxRequest.transactionContext.fingerprint || (signRawTxRequest.inputsVerified && !currentReview.inputsVerified) ) { throw new Error('Transaction review changed after approval was requested'); @@ -2216,11 +2717,21 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { rawTx: signRawTxRequest.rawTx, blockHeight: signRawTxRequest.reviewBlockHeight, accountAddress: signRawTxRequest.accountAddress, + txEngineSettings: signRawTxRequest.transactionContext.txEngineSettings, + transactionContextFingerprint: signRawTxRequest.transactionContext.fingerprint, + authorizationStillValid: () => isOriginApproved(signRawTxRequest.origin), }); const signedIntentId = String(wasm.spendsV1Hash(signedTx.spends)); if (signedIntentId !== signRawTxRequest.signingIntentId) { throw new Error('Signed transaction does not match the approved transaction'); } + if ( + vault.isLocked() || + vault.getCurrentAccount()?.address !== signRawTxRequest.accountAddress || + !isOriginApproved(signRawTxRequest.origin) + ) { + throw new Error('Signing account or authorization changed after approval'); + } approveSignRawTxPending.sendResponse({ tx: signedTx }); cancelPendingRequest(approveSignRawTxId); @@ -2274,6 +2785,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const approveConnectPending = pendingRequests.get(approveConnectId); if (approveConnectPending && isConnectRequest(approveConnectPending.request)) { const connectRequest = approveConnectPending.request; + const connectApprovalGeneration = approvedOrigins.captureGeneration(); if ( !(await validatePendingApproval(approveConnectId, approveConnectPending, sendResponse)) @@ -2282,27 +2794,66 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } try { + const approvedAccountAddress = connectRequest.accountAddress; + if ( + !approvedAccountAddress || + !pendingApprovalStillCurrent( + approveConnectId, + approveConnectPending, + connectApprovalGeneration, + approvedAccountAddress + ) + ) { + throw new Error('Wallet authorization changed; request the action again'); + } + const approveRpcConfig = await getEffectiveRpcConfig(); - const connectResponse = buildConnectResponse( - connectRequest.accountAddress!, - approveRpcConfig - ); + if ( + !pendingApprovalStillCurrent( + approveConnectId, + approveConnectPending, + connectApprovalGeneration, + approvedAccountAddress + ) + ) { + throw new Error('Wallet authorization changed; request the action again'); + } + const connectResponse = buildConnectResponse(approvedAccountAddress, approveRpcConfig); // Add origin only after the response can be built. - await approveOrigin(connectRequest.origin); + await approveOrigin(connectRequest.origin, connectApprovalGeneration); + if ( + !pendingApprovalStillCurrent( + approveConnectId, + approveConnectPending, + connectApprovalGeneration, + approvedAccountAddress + ) || + !isOriginApproved(connectRequest.origin) + ) { + throw new Error('Wallet authorization changed; request the action again'); + } approveConnectPending.sendResponse(connectResponse); cancelPendingRequest(approveConnectId); processNextRequest(); sendResponse({ success: true }); // Emit connect event - await emitWalletEvent('connect', { chainId: CHAIN_ID }); + await emitWalletEvent('connect', { chainId: CHAIN_ID }, () => + providerAuthorizationStillValid({ + generation: connectApprovalGeneration, + origin: connectRequest.origin, + accountAddress: approvedAccountAddress, + }) + ); } catch (err) { console.error('[Background] Failed to approve connection:', err); const errorMessage = err instanceof Error ? err.message : 'Failed to approve connection'; - cancelPendingRequest(approveConnectId, -32603, errorMessage); - processNextRequest(); + if (pendingRequests.get(approveConnectId) === approveConnectPending) { + cancelPendingRequest(approveConnectId, -32603, errorMessage); + processNextRequest(); + } sendResponse({ error: errorMessage }); } } else { @@ -2712,10 +3263,24 @@ chrome.alarms.onAlarm.addListener(async alarm => { const idleMs = Date.now() - lastActivity; if (idleMs >= autoLockMinutes * 60_000) { try { - await vault.lock(); - await clearUnlockSessionCache(); - // Notify popup to update UI immediately - await emitWalletEvent('LOCKED', { reason: 'auto-lock' }); + const generation = walletLifecycle.begin(); + activeExplicitLifecycleGeneration = generation; + const lockPromise = vault.lock(); + try { + await walletLifecycle.run(generation, async isCurrent => { + await lockPromise; + if (!isCurrent()) return; + subwalletDiscoveryAfterInitialSync.clear(); + await clearUnlockSessionCache(); + if (!isCurrent()) return; + // Notify popup to update UI immediately. + await emitWalletEvent('LOCKED', { reason: 'auto-lock' }, isCurrent); + }); + } finally { + if (activeExplicitLifecycleGeneration === generation) { + activeExplicitLifecycleGeneration = null; + } + } } catch (error) { console.error('Auto-lock failed:', error); } diff --git a/extension/shared/approved-origins-state.test.ts b/extension/shared/approved-origins-state.test.ts new file mode 100644 index 00000000..41c608ee --- /dev/null +++ b/extension/shared/approved-origins-state.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { ApprovedOriginsState } from './approved-origins-state'; + +describe('ApprovedOriginsState', () => { + it('orders a slow approval before reset so reset wins durably and in memory', async () => { + let releaseApproval!: () => void; + let approvalWriteStarted!: () => void; + const approvalStarted = new Promise(resolve => { + approvalWriteStarted = resolve; + }); + const approvalGate = new Promise(resolve => { + releaseApproval = resolve; + }); + let durableOrigins: string[] = []; + const writes: string[][] = []; + const state = new ApprovedOriginsState(async origins => { + writes.push(origins); + if (origins.includes('https://dapp.example')) { + approvalWriteStarted(); + await approvalGate; + } + durableOrigins = origins; + }); + + const approvalGeneration = state.captureGeneration(); + const approval = state.approve('https://dapp.example', approvalGeneration); + await approvalStarted; + + const resetGeneration = state.beginReset(); + const reset = state.runReset(resetGeneration, async () => { + writes.push([]); + durableOrigins = []; + }); + releaseApproval(); + + await expect(approval).rejects.toThrow('Wallet authorization changed'); + await reset; + state.finishReset(resetGeneration); + + expect(writes).toEqual([['https://dapp.example'], []]); + expect(durableOrigins).toEqual([]); + expect(state.has('https://dapp.example')).toBe(false); + }); + + it('publishes approve and revoke mutations only after persistence succeeds', async () => { + let fail = true; + const state = new ApprovedOriginsState(async () => { + if (fail) throw new Error('storage failed'); + }); + + await expect(state.approve('https://dapp.example')).rejects.toThrow('storage failed'); + expect(state.has('https://dapp.example')).toBe(false); + + fail = false; + await state.approve('https://dapp.example'); + expect(state.has('https://dapp.example')).toBe(true); + + fail = true; + await expect(state.revoke('https://dapp.example')).rejects.toThrow('storage failed'); + expect(state.has('https://dapp.example')).toBe(true); + }); +}); diff --git a/extension/shared/approved-origins-state.ts b/extension/shared/approved-origins-state.ts new file mode 100644 index 00000000..76ec0217 --- /dev/null +++ b/extension/shared/approved-origins-state.ts @@ -0,0 +1,101 @@ +import { SerializedTaskQueue } from './serialized-task-queue'; + +export class StaleApprovalStateError extends Error { + constructor() { + super('Wallet authorization changed; request the action again'); + this.name = 'StaleApprovalStateError'; + } +} + +/** + * Owns origin authorization and serializes its durable snapshots. + * Reset invalidation is synchronous, while runReset() orders the destructive + * storage clear after any write that had already started. + */ +export class ApprovedOriginsState { + private origins = new Set(); + private generation = 0; + private resetGeneration: number | null = null; + private readonly persistenceQueue = new SerializedTaskQueue(); + + constructor(private readonly persistOrigins: (origins: string[]) => Promise) {} + + captureGeneration(): number { + return this.generation; + } + + isCurrent(generation: number): boolean { + return generation === this.generation && this.resetGeneration === null; + } + + has(origin: string): boolean { + return this.origins.has(origin); + } + + values(): ReadonlySet { + return this.origins; + } + + replace(origins: Iterable, generation: number): void { + this.assertCurrent(generation); + this.origins = new Set(origins); + } + + async persist(generation: number = this.generation): Promise { + await this.persistenceQueue.run(async () => { + this.assertCurrent(generation); + await this.persistOrigins(Array.from(this.origins)); + this.assertCurrent(generation); + }); + } + + async approve(origin: string, generation: number = this.generation): Promise { + await this.persistenceQueue.run(async () => { + this.assertCurrent(generation); + const nextOrigins = new Set(this.origins); + nextOrigins.add(origin); + await this.persistOrigins(Array.from(nextOrigins)); + this.assertCurrent(generation); + this.origins = nextOrigins; + }); + } + + async revoke(origin: string, generation: number = this.generation): Promise { + await this.persistenceQueue.run(async () => { + this.assertCurrent(generation); + const nextOrigins = new Set(this.origins); + nextOrigins.delete(origin); + await this.persistOrigins(Array.from(nextOrigins)); + this.assertCurrent(generation); + this.origins = nextOrigins; + }); + } + + beginReset(): number { + this.generation += 1; + this.resetGeneration = this.generation; + this.origins.clear(); + return this.generation; + } + + async runReset(generation: number, resetStorage: () => Promise): Promise { + return this.persistenceQueue.run(async () => { + if (generation !== this.generation || this.resetGeneration !== generation) { + throw new StaleApprovalStateError(); + } + return resetStorage(); + }); + } + + finishReset(generation: number): void { + if (this.resetGeneration === generation) { + this.resetGeneration = null; + } + } + + private assertCurrent(generation: number): void { + if (!this.isCurrent(generation)) { + throw new StaleApprovalStateError(); + } + } +} diff --git a/extension/shared/build-request-admission.test.ts b/extension/shared/build-request-admission.test.ts new file mode 100644 index 00000000..4d843cd5 --- /dev/null +++ b/extension/shared/build-request-admission.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { BuildRequestAdmission } from './build-request-admission'; + +describe('BuildRequestAdmission', () => { + it('fails concurrent builds fast and releases capacity exactly once', () => { + const admission = new BuildRequestAdmission(1); + const release = admission.tryAcquire('https://first.example'); + + expect(release).toBeTypeOf('function'); + expect(admission.tryAcquire('https://first.example')).toBeNull(); + expect(admission.tryAcquire('https://second.example')).toBeNull(); + + release!(); + release!(); + expect(admission.tryAcquire('https://second.example')).toBeTypeOf('function'); + }); +}); diff --git a/extension/shared/build-request-admission.ts b/extension/shared/build-request-admission.ts new file mode 100644 index 00000000..e0e6624b --- /dev/null +++ b/extension/shared/build-request-admission.ts @@ -0,0 +1,24 @@ +/** Fail-fast admission control for read-only public transaction builds. */ +export class BuildRequestAdmission { + private readonly activeOrigins = new Set(); + + constructor(private readonly maxConcurrent: number) { + if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) { + throw new Error('Build concurrency limit must be a positive integer'); + } + } + + tryAcquire(origin: string): (() => void) | null { + if (this.activeOrigins.has(origin) || this.activeOrigins.size >= this.maxConcurrent) { + return null; + } + this.activeOrigins.add(origin); + + let active = true; + return () => { + if (!active) return; + active = false; + this.activeOrigins.delete(origin); + }; + } +} diff --git a/extension/shared/constants.ts b/extension/shared/constants.ts index 9e4173e7..54defd22 100644 --- a/extension/shared/constants.ts +++ b/extension/shared/constants.ts @@ -9,13 +9,13 @@ import { PROVIDER_METHODS as SDK_PROVIDER_METHODS } from '@nockbox/iris-sdk'; /** * Provider methods: SDK methods plus methods added locally ahead of the next SDK release. - * TODO: drop the local ESTIMATE_TRANSACTION_FEE entry once @nockbox/iris-sdk >= 0.3.0 - * (which defines it) is published and the dependency is upgraded. + * TODO: drop the local BUILD_SIMPLE_TRANSACTION entry once @nockbox/iris-sdk >= 0.3.0 + * (which owns this public API) is published and the dependency is upgraded. */ export const PROVIDER_METHODS = { ...SDK_PROVIDER_METHODS, - /** Estimate transaction fee for a dApp send (read-only, no approval popup) */ - ESTIMATE_TRANSACTION_FEE: 'nock_estimateTransactionFee', + /** Build an exact unsigned dApp payment intent (read-only, no approval popup) */ + BUILD_SIMPLE_TRANSACTION: 'nock_buildSimpleTransaction', } as const; /** diff --git a/extension/shared/lifecycle-transition-coordinator.test.ts b/extension/shared/lifecycle-transition-coordinator.test.ts new file mode 100644 index 00000000..3b273f62 --- /dev/null +++ b/extension/shared/lifecycle-transition-coordinator.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { LifecycleTransitionCoordinator } from './lifecycle-transition-coordinator'; + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise(done => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('LifecycleTransitionCoordinator', () => { + it('orders effects and prevents an invalidated unlock from running later effects', async () => { + const coordinator = new LifecycleTransitionCoordinator(); + const delayedWrite = deferred(); + const effects: string[] = []; + + const unlockGeneration = coordinator.begin(); + const unlock = coordinator.run(unlockGeneration, async isCurrent => { + effects.push('unlock-write-start'); + await delayedWrite.promise; + effects.push('unlock-write-finish'); + if (isCurrent()) effects.push('connect'); + }); + + await Promise.resolve(); + const lockGeneration = coordinator.begin(); + const lock = coordinator.run(lockGeneration, async () => { + effects.push('lock-marker'); + effects.push('disconnect'); + }); + + delayedWrite.resolve(); + await Promise.all([unlock, lock]); + + expect(effects).toEqual([ + 'unlock-write-start', + 'unlock-write-finish', + 'lock-marker', + 'disconnect', + ]); + }); + + it('does not start queued stale setup work', async () => { + const coordinator = new LifecycleTransitionCoordinator(); + const hold = deferred(); + const firstGeneration = coordinator.begin(); + const first = coordinator.run(firstGeneration, async () => hold.promise); + await Promise.resolve(); + + const staleGeneration = coordinator.begin(); + const stale = coordinator.run(staleGeneration, async () => 'stale'); + const resetGeneration = coordinator.begin(); + const reset = coordinator.run(resetGeneration, async () => 'reset'); + + hold.resolve(); + await expect(first).resolves.toBeUndefined(); + await expect(stale).resolves.toBeUndefined(); + await expect(reset).resolves.toBe('reset'); + }); +}); diff --git a/extension/shared/lifecycle-transition-coordinator.ts b/extension/shared/lifecycle-transition-coordinator.ts new file mode 100644 index 00000000..7659abb6 --- /dev/null +++ b/extension/shared/lifecycle-transition-coordinator.ts @@ -0,0 +1,30 @@ +import { SerializedTaskQueue } from './serialized-task-queue'; + +/** + * Orders wallet lifecycle side effects while allowing a newer transition to + * invalidate slow work immediately. Callers still perform any synchronous + * Vault lock/epoch invalidation before waiting on `run`. + */ +export class LifecycleTransitionCoordinator { + private generation = 0; + private readonly queue = new SerializedTaskQueue(); + + begin(): number { + this.generation += 1; + return this.generation; + } + + isCurrent(generation: number): boolean { + return generation === this.generation; + } + + run( + generation: number, + task: (isCurrent: () => boolean) => Promise + ): Promise { + return this.queue.run(async () => { + if (!this.isCurrent(generation)) return undefined; + return await task(() => this.isCurrent(generation)); + }); + } +} diff --git a/extension/shared/pending-approval-state.ts b/extension/shared/pending-approval-state.ts index 89cc4e06..5485b3cd 100644 --- a/extension/shared/pending-approval-state.ts +++ b/extension/shared/pending-approval-state.ts @@ -127,6 +127,16 @@ export function pendingApprovalOriginMatches( return currentOrigin !== null && request.origin === currentOrigin; } +/** Connection requests establish permission; every other approval must retain it. */ +export function pendingApprovalPermissionStillValid( + request: PendingRequestLike['request'], + approvedOrigins: ReadonlySet +): boolean { + const isConnectionRequest = + !('to' in request) && !('message' in request) && !('rawTx' in request); + return isConnectionRequest || approvedOrigins.has(request.origin); +} + export function pendingApprovalAccountMatches( request: PendingRequestLike['request'], currentAccountAddress: string | null diff --git a/extension/shared/pending-approvals-session.test.ts b/extension/shared/pending-approvals-session.test.ts index 40014460..6d5d685c 100644 --- a/extension/shared/pending-approvals-session.test.ts +++ b/extension/shared/pending-approvals-session.test.ts @@ -1,12 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@nockbox/iris-sdk', () => ({ + PROVIDER_METHODS: {}, +})); import type { ConnectRequest, SignRawTxRequest, TransactionRequest } from './types'; import { buildPendingApprovalSessionSnapshot, pendingApprovalAccountMatches, pendingApprovalOriginMatches, + pendingApprovalPermissionStillValid, restorePendingApprovalSessionSnapshot, type PendingRequestLike, } from './pending-approval-state'; +import { PendingApprovalSessionPersistence } from './pending-approvals-session'; const ACTIVE_TIMESTAMP = 1_000; const ORIGIN = 'https://app.example'; @@ -73,6 +79,48 @@ function pendingMap( const isExpired = (timestamp: number) => timestamp < ACTIVE_TIMESTAMP; describe('pending approval session state', () => { + it('orders a slow snapshot before reset and leaves the session empty', async () => { + const snapshot = buildPendingApprovalSessionSnapshot( + pendingMap([['connect', connectRequest('connect')]]), + 'connect', + 'connect', + [], + isExpired + )!; + let releaseOldWrite!: () => void; + let oldWriteStarted!: () => void; + const oldWriteGate = new Promise(resolve => { + releaseOldWrite = resolve; + }); + const oldWriteIsRunning = new Promise(resolve => { + oldWriteStarted = resolve; + }); + let durableSnapshot = null as typeof snapshot | null; + const writes: Array = []; + const persistence = new PendingApprovalSessionPersistence(async nextSnapshot => { + writes.push(nextSnapshot); + if (nextSnapshot) { + oldWriteStarted(); + await oldWriteGate; + } + durableSnapshot = nextSnapshot; + }); + + const oldWrite = persistence.persist(snapshot); + await oldWriteIsRunning; + const resetGeneration = persistence.beginReset(); + await persistence.persist(snapshot); + const reset = persistence.clearForReset(resetGeneration); + releaseOldWrite(); + + await oldWrite; + await expect(reset).resolves.toBe(true); + persistence.finishReset(resetGeneration); + + expect(writes).toEqual([snapshot, null]); + expect(durableSnapshot).toBeNull(); + }); + it('excludes native raw approvals from session storage', () => { const snapshot = buildPendingApprovalSessionSnapshot( pendingMap([['raw', rawRequest('raw')]]), @@ -150,6 +198,90 @@ describe('pending approval session state', () => { }); }); + it('structured-clones an unsigned build snapshot across a worker restart', () => { + const request: TransactionRequest = { + ...transactionRequest('transaction'), + builtTransaction: { + tx: { + version: 1, + id: 'unsigned-id', + spends: [], + display: { inputs: { inputs: [] }, outputs: [] }, + witness_data: { data: [] }, + } as unknown as NonNullable['tx'], + notes: [ + { + version: 1, + origin_page: 10, + name: { first: 'first', last: 'last', _sig: 0 }, + note_data: [], + assets: '11', + }, + ] as unknown as NonNullable['notes'], + outputs: [], + intentId: 'intent-id' as NonNullable['intentId'], + accountAddress: ACCOUNT as NonNullable< + TransactionRequest['builtTransaction'] + >['accountAddress'], + blockHeight: 10, + to: 'recipient' as NonNullable['to'], + amount: '10' as TransactionRequest['amount'], + inputTotal: '11' as TransactionRequest['amount'], + fee: '1' as TransactionRequest['fee'], + minimumFee: '1' as TransactionRequest['fee'], + change: '0' as TransactionRequest['amount'], + }, + transactionContext: { + fingerprint: 'network-fingerprint', + networkIdentity: 'network-identity', + rpcUrl: 'https://rpc.example', + networkName: 'Testnet', + coinbaseTimelockBlocks: 100, + txEngineActivationHeight: 0, + nextTxEngineActivationHeight: 100, + txEngineSettings: { + tx_engine_version: 1, + tx_engine_patch: 0, + min_fee: '1', + cost_per_word: '1', + witness_word_div: 1, + } as NonNullable['txEngineSettings'], + }, + }; + const snapshot = buildPendingApprovalSessionSnapshot( + pendingMap([['transaction', request]]), + 'transaction', + 'transaction', + [], + isExpired + )!; + + const clonedSnapshot = structuredClone(snapshot); + const restored = restorePendingApprovalSessionSnapshot(clonedSnapshot, isExpired); + + expect(restored.pending.transaction.request).toMatchObject({ + builtTransaction: { + tx: { version: 1, id: 'unsigned-id' }, + notes: [{ version: 1, assets: '11' }], + intentId: 'intent-id', + accountAddress: ACCOUNT, + to: 'recipient', + amount: '10', + inputTotal: '11', + fee: '1', + minimumFee: '1', + change: '0', + }, + transactionContext: { + fingerprint: 'network-fingerprint', + networkIdentity: 'network-identity', + rpcUrl: 'https://rpc.example', + txEngineActivationHeight: 0, + nextTxEngineActivationHeight: 100, + }, + }); + }); + it('drops expired requests without blocking later queued approvals', () => { const snapshot = buildPendingApprovalSessionSnapshot( pendingMap([ @@ -177,6 +309,14 @@ describe('pending approval session state', () => { expect(pendingApprovalOriginMatches(request, null)).toBe(false); }); + it('invalidates sensitive approvals when origin permission is revoked', () => { + const request = transactionRequest('transaction'); + + expect(pendingApprovalPermissionStillValid(request, new Set([ORIGIN]))).toBe(true); + expect(pendingApprovalPermissionStillValid(request, new Set())).toBe(false); + expect(pendingApprovalPermissionStillValid(connectRequest('connect'), new Set())).toBe(true); + }); + it('requires the account selected when the approval was created', () => { const request = transactionRequest('transaction'); diff --git a/extension/shared/pending-approvals-session.ts b/extension/shared/pending-approvals-session.ts index 6591c15c..0d2f0ea6 100644 --- a/extension/shared/pending-approvals-session.ts +++ b/extension/shared/pending-approvals-session.ts @@ -1,5 +1,6 @@ import { SESSION_STORAGE_KEYS } from './constants'; import type { PendingApprovalSessionSnapshot } from './pending-approval-state'; +import { SerializedTaskQueue } from './serialized-task-queue'; export async function persistPendingApprovalSession( snapshot: PendingApprovalSessionSnapshot | null @@ -32,3 +33,62 @@ export async function loadPendingApprovalSession(): Promise Promise = persistPendingApprovalSession + ) {} + + persist(snapshot: PendingApprovalSessionSnapshot | null): Promise { + const generation = this.generation; + if (this.resetGeneration !== null) { + return Promise.resolve(); + } + + return this.queue.run(async () => { + if (generation !== this.generation || this.resetGeneration !== null) { + return; + } + await this.write(snapshot); + }); + } + + beginReset(): number { + this.generation += 1; + this.resetGeneration = this.generation; + return this.generation; + } + + async clearForReset(generation: number): Promise { + return this.queue.run(async () => { + if (generation !== this.generation || this.resetGeneration !== generation) { + return false; + } + await this.write(null); + return true; + }); + } + + finishReset(generation: number): void { + if (this.resetGeneration === generation) { + this.resetGeneration = null; + } + } + + isResetting(): boolean { + return this.resetGeneration !== null; + } +} diff --git a/extension/shared/rpc-config-context.test.ts b/extension/shared/rpc-config-context.test.ts new file mode 100644 index 00000000..9d49b256 --- /dev/null +++ b/extension/shared/rpc-config-context.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TxEngineSettings } from '@nockbox/iris-sdk/wasm'; + +vi.mock('@nockbox/iris-sdk', () => ({ + PROVIDER_METHODS: {}, + DEFAULT_COINBASE_TIMELOCK_BLOCKS: 100, + DEFAULT_TX_ENGINE_ACTIVATION_HEIGHTS: { + 0: { + tx_engine_version: 1, + tx_engine_patch: 0, + min_fee: '1', + cost_per_word: '1', + witness_word_div: 1, + }, + }, +})); + +import { + assertRpcNetworkIdentity, + getRpcNetworkIdentity, + getTransactionContextFingerprint, + type RpcConfig, + type TxEngineActivationHeights, +} from './rpc-config'; + +function settings(patch: number, minimumFee: string): TxEngineSettings { + return { + tx_engine_version: 1, + tx_engine_patch: patch, + min_fee: minimumFee, + cost_per_word: '1', + witness_word_div: 1, + } as TxEngineSettings; +} + +function config( + overrides: Partial = {}, + activations: TxEngineActivationHeights = { + 0: settings(0, '1'), + 100: settings(1, '2'), + } +): RpcConfig { + return { + rpcUrl: 'https://rpc.example', + networkName: 'Testnet', + blockExplorerUrl: 'https://explorer.example', + coinbaseTimelockBlocks: 100, + txEngineActivationHeights: activations, + ...overrides, + }; +} + +describe('transaction context fingerprints', () => { + it('survives ordinary height advances but changes across an activation', () => { + expect(getTransactionContextFingerprint(config(), 50)).toBe( + getTransactionContextFingerprint(config(), 99) + ); + expect(getTransactionContextFingerprint(config(), 99)).not.toBe( + getTransactionContextFingerprint(config(), 100) + ); + }); + + it('binds RPC, network, timelock, and the complete activation schedule', () => { + const baseline = getTransactionContextFingerprint(config(), 50); + + expect( + getTransactionContextFingerprint(config({ rpcUrl: 'https://other-rpc.example' }), 50) + ).not.toBe(baseline); + expect(getTransactionContextFingerprint(config({ networkName: 'Other testnet' }), 50)).not.toBe( + baseline + ); + expect(getTransactionContextFingerprint(config({ coinbaseTimelockBlocks: 200 }), 50)).not.toBe( + baseline + ); + expect( + getTransactionContextFingerprint( + config({}, { 0: settings(0, '1'), 100: settings(2, '2') }), + 50 + ) + ).not.toBe(baseline); + }); + + it('is canonical across URL and activation-map insertion ordering', () => { + const forward = config({}, { 0: settings(0, '1'), 100: settings(1, '2') }); + const reverse = config( + { rpcUrl: 'https://rpc.example/' }, + { 100: settings(1, '2'), 0: settings(0, '1') } + ); + + expect(getTransactionContextFingerprint(forward, 50)).toBe( + getTransactionContextFingerprint(reverse, 50) + ); + expect( + getTransactionContextFingerprint( + { ...forward, blockExplorerUrl: 'https://unrelated-explorer.example' }, + 50 + ) + ).toBe(getTransactionContextFingerprint(forward, 50)); + }); +}); + +describe('RPC network cache identity', () => { + it('normalizes equivalent endpoints and excludes explorer-only settings', () => { + const baseline = getRpcNetworkIdentity(config()); + expect(getRpcNetworkIdentity(config({ rpcUrl: 'https://rpc.example/' }))).toBe(baseline); + expect( + getRpcNetworkIdentity(config({ blockExplorerUrl: 'https://other-explorer.example' })) + ).toBe(baseline); + }); + + it('requires resync after an endpoint change but not a label-only rename', () => { + const synced = getRpcNetworkIdentity(config()); + const changedEndpoint = getRpcNetworkIdentity(config({ rpcUrl: 'https://other-rpc.example' })); + const changedNetwork = getRpcNetworkIdentity(config({ networkName: 'Other testnet' })); + + expect(() => assertRpcNetworkIdentity(synced, changedEndpoint)).toThrow(/must be synced/i); + expect(changedNetwork).toBe(synced); + }); + + it('requires a resync for legacy caches without provenance', () => { + expect(() => assertRpcNetworkIdentity(undefined, getRpcNetworkIdentity(config()))).toThrow( + /must be synced/i + ); + }); +}); diff --git a/extension/shared/rpc-config.ts b/extension/shared/rpc-config.ts index 2193ff04..9e1e8024 100644 --- a/extension/shared/rpc-config.ts +++ b/extension/shared/rpc-config.ts @@ -27,6 +27,24 @@ export interface RpcConfig { coinbaseTimelockBlocks?: number; } +/** + * Effective transaction semantics captured from one storage read. The + * fingerprint intentionally excludes the current block height itself so an + * approval remains valid while the chain advances within the same activation + * band. Crossing an activation boundary changes the fingerprint. + */ +export type TransactionContextSnapshot = Readonly<{ + fingerprint: string; + /** Stable endpoint + logical-network identity used to source cached UTXOs. */ + networkIdentity: string; + rpcUrl: string; + networkName: string; + coinbaseTimelockBlocks: number; + txEngineActivationHeight: number; + nextTxEngineActivationHeight?: number; + txEngineSettings: TxEngineSettings; +}>; + /** Stored config is partial; unset keys fall back to defaults */ export type StoredRpcConfig = Partial; @@ -175,6 +193,109 @@ export async function clearRpcConfig(): Promise { await chrome.storage.local.remove(STORAGE_KEYS.RPC_CONFIG); } +function txEngineActivationEntries(config: RpcConfig): Array { + const heights = + config.txEngineActivationHeights ?? + defaultRpcConfig.txEngineActivationHeights ?? + DEFAULT_WALLET_TX_ENGINE_ACTIVATION_HEIGHTS; + + return Object.entries(heights) + .map(([height, settings]) => [Number(height), settings] as const) + .filter(([height]) => Number.isFinite(height)) + .sort(([a], [b]) => a - b); +} + +function activeTxEngineContext( + config: RpcConfig, + blockHeight: number +): { activationHeight: number; settings: TxEngineSettings } { + const eligible = txEngineActivationEntries(config).filter(([height]) => height <= blockHeight); + const active = eligible[eligible.length - 1]; + if (!active) { + throw new Error(`No tx engine available for height ${blockHeight}`); + } + return { activationHeight: active[0], settings: active[1] }; +} + +function canonicalizeContextValue(value: unknown): unknown { + if (typeof value === 'bigint') return value.toString(); + if (Array.isArray(value)) return value.map(canonicalizeContextValue); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => [key, canonicalizeContextValue(entry)]) + ); + } + return value; +} + +/** + * Stable identity for chain-derived wallet data. A successful account sync is + * tagged with this value; transaction builders reject untagged or mismatched + * caches so changing RPC/network settings cannot mix notes from two chains. + */ +export function getRpcNetworkIdentity(config: RpcConfig): string { + return JSON.stringify({ + schema: 1, + rpcUrl: normalizeRpcUrl(config.rpcUrl || RPC_ENDPOINT), + // The configured name is a user-editable label, not chain identity. A + // future RPC chain/genesis ID can be added here without treating renames as + // a destructive network switch. + }); +} + +export function assertRpcNetworkIdentity( + syncedIdentity: string | undefined, + currentIdentity: string +): void { + if (!syncedIdentity || syncedIdentity !== currentIdentity) { + throw new Error('Wallet data must be synced with the current network before transacting'); + } +} + +/** + * Stable, collision-free canonical representation of the configuration that + * can change how a transaction is built, signed, or submitted. + */ +export function getTransactionContextFingerprint(config: RpcConfig, blockHeight: number): string { + const active = activeTxEngineContext(config, blockHeight); + const descriptor = { + schema: 1, + rpcUrl: normalizeRpcUrl(config.rpcUrl || RPC_ENDPOINT), + networkName: config.networkName, + coinbaseTimelockBlocks: config.coinbaseTimelockBlocks ?? DEFAULT_COINBASE_TIMELOCK_BLOCKS, + txEngineActivationHeights: txEngineActivationEntries(config).map(([height, settings]) => ({ + height, + settings, + })), + activeTxEngineActivationHeight: active.activationHeight, + activeTxEngineSettings: active.settings, + }; + return JSON.stringify(canonicalizeContextValue(descriptor)); +} + +/** Capture every transaction-context value from one effective config read. */ +export async function getTransactionContextSnapshot( + blockHeight: number +): Promise { + const config = await getEffectiveRpcConfig(); + const active = activeTxEngineContext(config, blockHeight); + const nextActivation = txEngineActivationEntries(config).find(([height]) => height > blockHeight); + const txEngineSettings = Object.freeze({ ...active.settings }); + + return Object.freeze({ + fingerprint: getTransactionContextFingerprint(config, blockHeight), + networkIdentity: getRpcNetworkIdentity(config), + rpcUrl: config.rpcUrl, + networkName: config.networkName, + coinbaseTimelockBlocks: config.coinbaseTimelockBlocks ?? DEFAULT_COINBASE_TIMELOCK_BLOCKS, + txEngineActivationHeight: active.activationHeight, + nextTxEngineActivationHeight: nextActivation?.[0], + txEngineSettings, + }); +} + /** * Resolve TxEngineSettings for a given block height. * Returns the settings for the largest activation height <= blockHeight. @@ -182,17 +303,5 @@ export async function clearRpcConfig(): Promise { */ export async function getTxEngineSettingsForHeight(blockHeight: number): Promise { const config = await getEffectiveRpcConfig(); - const heights = - config.txEngineActivationHeights ?? - defaultRpcConfig.txEngineActivationHeights ?? - DEFAULT_WALLET_TX_ENGINE_ACTIVATION_HEIGHTS; - const sorted = Object.keys(heights) - .map(Number) - .filter(h => h <= blockHeight) - .sort((a, b) => b - a); - const best = sorted[0]; - if (best === undefined) { - throw new Error(`No tx engine available for height ${blockHeight}`); - } - return heights[best]; + return activeTxEngineContext(config, blockHeight).settings; } diff --git a/extension/shared/rpc-methods.test.ts b/extension/shared/rpc-methods.test.ts index 5241f161..c4a5107b 100644 --- a/extension/shared/rpc-methods.test.ts +++ b/extension/shared/rpc-methods.test.ts @@ -13,14 +13,14 @@ vi.mock('@nockbox/iris-sdk', () => ({ import { INTERNAL_METHODS, PROVIDER_METHODS, RPC_METHODS } from './constants'; import type { RPCMethod } from './types'; -const publicFeeMethod: RPCMethod = 'nock_estimateTransactionFee'; +const publicBuildMethod: RPCMethod = 'nock_buildSimpleTransaction'; const internalFeeMethod: RPCMethod = 'wallet:estimateTransactionFee'; -describe('fee RPC method constants', () => { - it('keeps the public and internal wire methods in the combined method contract', () => { - expect(PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE).toBe(publicFeeMethod); +describe('transaction build RPC method constants', () => { + it('keeps the public build and internal fee methods in the combined contract', () => { + expect(PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION).toBe(publicBuildMethod); expect(INTERNAL_METHODS.ESTIMATE_SEND_FEE).toBe(internalFeeMethod); - expect(RPC_METHODS.ESTIMATE_TRANSACTION_FEE).toBe(publicFeeMethod); + expect(RPC_METHODS.BUILD_SIMPLE_TRANSACTION).toBe(publicBuildMethod); expect(RPC_METHODS.ESTIMATE_SEND_FEE).toBe(internalFeeMethod); }); }); diff --git a/extension/shared/serialized-task-queue.test.ts b/extension/shared/serialized-task-queue.test.ts new file mode 100644 index 00000000..10fd13b3 --- /dev/null +++ b/extension/shared/serialized-task-queue.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { SerializedTaskQueue } from './serialized-task-queue'; + +describe('SerializedTaskQueue', () => { + it('runs writes in order and captures state when each queued task executes', async () => { + const queue = new SerializedTaskQueue(); + let releaseFirst!: () => void; + const firstGate = new Promise(resolve => { + releaseFirst = resolve; + }); + const events: string[] = []; + let state = 'old'; + + const first = queue.run(async () => { + events.push(`first:${state}`); + await firstGate; + }); + const second = queue.run(async () => { + events.push(`second:${state}`); + }); + + await Promise.resolve(); + state = 'new'; + expect(events).toEqual(['first:old']); + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual(['first:old', 'second:new']); + }); + + it('does not let a failed write poison later queued work', async () => { + const queue = new SerializedTaskQueue(); + const failed = queue.run(async () => { + throw new Error('storage failed'); + }); + const succeeded = queue.run(async () => 'persisted'); + + await expect(failed).rejects.toThrow('storage failed'); + await expect(succeeded).resolves.toBe('persisted'); + await expect(queue.drain()).resolves.toBeUndefined(); + }); +}); diff --git a/extension/shared/serialized-task-queue.ts b/extension/shared/serialized-task-queue.ts new file mode 100644 index 00000000..2c996eed --- /dev/null +++ b/extension/shared/serialized-task-queue.ts @@ -0,0 +1,18 @@ +/** Serialize asynchronous state snapshots without letting one failure poison later writes. */ +export class SerializedTaskQueue { + private tail: Promise = Promise.resolve(); + + run(task: () => Promise): Promise { + const result = this.tail.then(task); + this.tail = result.then( + () => undefined, + () => undefined + ); + return result; + } + + /** Wait for every task that was enqueued before this call to settle. */ + async drain(): Promise { + await this.tail; + } +} diff --git a/extension/shared/side-panel.test.ts b/extension/shared/side-panel.test.ts index e1b44fd9..8bb22074 100644 --- a/extension/shared/side-panel.test.ts +++ b/extension/shared/side-panel.test.ts @@ -55,5 +55,10 @@ describe('side panel gesture routing', () => { expect( isSidePanelGestureRequest(request(PROVIDER_METHODS.GET_WALLET_INFO, DISPLAY_MODES.SIDE_PANEL)) ).toBe(false); + expect( + isSidePanelGestureRequest( + request(PROVIDER_METHODS.BUILD_SIMPLE_TRANSACTION, DISPLAY_MODES.SIDE_PANEL) + ) + ).toBe(false); }); }); diff --git a/extension/shared/transaction-builder.ts b/extension/shared/transaction-builder.ts index a5409458..5bace239 100644 --- a/extension/shared/transaction-builder.ts +++ b/extension/shared/transaction-builder.ts @@ -10,6 +10,7 @@ import { publicKeyToPKHDigest } from './address-encoding.js'; import { base58 } from '@scure/base'; import { DEFAULT_COINBASE_TIMELOCK_BLOCKS } from '@nockbox/iris-sdk'; import { getEffectiveRpcConfig, getTxEngineSettingsForHeight } from './rpc-config.js'; +import type { TransactionContextSnapshot } from './rpc-config.js'; import { ensureWasmInitialized } from './wasm-utils.js'; import { createSimplePkhCondition, @@ -19,22 +20,22 @@ import { parseDigestString, } from './spend-conditions.js'; import { firstNameFromCondition } from './first-name-derivation.js'; +import { resolveBuilderFeeSummary } from './transaction-fee.js'; type SpendConditionLike = wasm.SpendCondition; function noteFromProtobuf(protoNote: any): any { return wasm.noteFromProtobuf(protoNote); } -async function createTxBuilder(blockHeight?: number): Promise { +async function createTxBuilder( + blockHeight?: number, + txEngineSettings?: wasm.TxEngineSettings +): Promise { const height = blockHeight ?? 0; - const settings = await getTxEngineSettingsForHeight(height); + const settings = txEngineSettings ?? (await getTxEngineSettingsForHeight(height)); return new wasm.TxBuilder(settings); } -function getFeeFromBuilder(builder: wasm.TxBuilder): number { - return Number(builder.calcFee()); -} - function getTxIdCompat(nockchainTx: wasm.NockchainTx): string { return nockchainTx.id; } @@ -57,12 +58,15 @@ function isSpendConditionList( */ export async function discoverSpendConditionForNote( senderPKH: string, - note: { nameFirst: string; originPage: number } + note: { nameFirst: string; originPage: number }, + coinbaseTimelockBlocks?: number ): Promise { await ensureWasmInitialized(); - const config = await getEffectiveRpcConfig(); - const timelock = config.coinbaseTimelockBlocks ?? DEFAULT_COINBASE_TIMELOCK_BLOCKS; + const timelock = + coinbaseTimelockBlocks ?? + (await getEffectiveRpcConfig()).coinbaseTimelockBlocks ?? + DEFAULT_COINBASE_TIMELOCK_BLOCKS; const timelockBigInt = BigInt(timelock); const candidates: Array<{ name: string; condition: SpendConditionLike }> = []; @@ -148,8 +152,12 @@ export interface TransactionParams { includeLockData: boolean; /** Current block height (for tx engine selection by activation height). If omitted, uses tx-engine-1. */ blockHeight?: number; + /** Pre-resolved settings used to bind construction to one immutable config snapshot. */ + txEngineSettings?: wasm.TxEngineSettings; } +export type UnsignedTransactionParams = Omit; + /** * Constructed transaction ready for broadcast */ @@ -162,16 +170,15 @@ export interface ConstructedTransaction { nockchainTx: wasm.NockchainTx; /** Fee used in the transaction (in nicks) */ feeUsed: number; + /** Minimum fee required by the transaction engine (in nicks) */ + minimumFee: number; } -/** - * Build a complete Nockchain transaction using the new builder API - * - * @param params - Transaction parameters - * @returns Constructed transaction ready for broadcast - */ -export async function buildTransaction(params: TransactionParams): Promise { - // Initialize both WASM modules +async function prepareTransactionBuilder(params: UnsignedTransactionParams): Promise<{ + builder: wasm.TxBuilder; + feeUsed: number; + minimumFee: number; +}> { await ensureWasmInitialized(); const { @@ -181,17 +188,15 @@ export async function buildTransaction(params: TransactionParams): Promise sum + BigInt(Math.floor(note.assets)), 0n); const amountBn = nicksToBigInt(amount); const feeBn = fee !== undefined ? nicksToBigInt(fee) : 0n; @@ -203,7 +208,6 @@ export async function buildTransaction(params: TransactionParams): Promise { if (!note.protoNote) { throw new Error( @@ -213,14 +217,11 @@ export async function buildTransaction(params: TransactionParams): Promise spendCondition) // Single condition applied to all notes - : notes.map(() => spendCondition); // Single condition applied to all notes + ? spendCondition + : notes.map(() => spendCondition) + : notes.map(() => spendCondition); if (spendConditions.length !== notes.length) { throw new Error( @@ -228,40 +229,85 @@ export async function buildTransaction(params: TransactionParams): Promise ({ - lock: sc, - lock_sp_index: 0, - })); - builder.simpleSpend( - wasmNotes, - locks, - parseDigestString(recipientPKH), - amount as wasm.Nicks, - fee !== undefined ? (fee as wasm.Nicks) : null, - parseDigestString(refundPKH), - includeLockData - ); + const builder = await createTxBuilder(blockHeight, txEngineSettings); + try { + const locks: wasm.TxLock[] = spendConditions.map(sc => ({ + lock: sc, + lock_sp_index: 0, + })); + builder.simpleSpend( + wasmNotes, + locks, + parseDigestString(recipientPKH), + amount as wasm.Nicks, + fee !== undefined ? (fee as wasm.Nicks) : null, + parseDigestString(refundPKH), + includeLockData + ); + + const { fee: feeUsed, minimumFee } = resolveBuilderFeeSummary( + builder.curFee(), + builder.calcFee(), + fee !== undefined + ); - // Sign and validate the transaction - await builder.sign(privateKey); - builder.validate(); + return { builder, feeUsed, minimumFee }; + } catch (error) { + builder.free(); + throw error; + } +} - // Get the fee before building (for return value) - const feeUsed = getFeeFromBuilder(builder); +/** Build an unsigned transaction without deriving or accessing a private key. */ +export async function buildUnsignedTransaction( + params: UnsignedTransactionParams +): Promise { + const { builder, feeUsed, minimumFee } = await prepareTransactionBuilder(params); + try { + const nockchainTx = builder.build(); + return { + txId: getTxIdCompat(nockchainTx), + version: 1, + nockchainTx, + feeUsed, + minimumFee, + }; + } finally { + builder.free(); + } +} - // Build the final transaction - const nockchainTx = builder.build(); +/** + * Build a complete Nockchain transaction using the new builder API + * + * @param params - Transaction parameters + * @returns Constructed transaction ready for broadcast + */ +export async function buildTransaction(params: TransactionParams): Promise { + const { builder } = await prepareTransactionBuilder(params); + try { + await builder.sign(params.privateKey); + builder.validate(); + + // Signing may change witness size. Report the actual assigned fee and the + // final minimum independently instead of conflating calcFee() with fee used. + const { fee: feeUsed, minimumFee } = resolveBuilderFeeSummary( + builder.curFee(), + builder.calcFee(), + params.fee !== undefined + ); - return { - txId: getTxIdCompat(nockchainTx), - version: 1, // V1 only - nockchainTx, - feeUsed, - }; + const nockchainTx = builder.build(); + return { + txId: getTxIdCompat(nockchainTx), + version: 1, + nockchainTx, + feeUsed, + minimumFee, + }; + } finally { + builder.free(); + } } /** @@ -281,48 +327,36 @@ export async function buildTransaction(params: TransactionParams): Promise { - // Initialize WASM - await ensureWasmInitialized(); - +function validatePaymentCandidates(notes: Note[], amount: Nicks, fee?: Nicks): void { if (notes.length === 0) { throw new Error('At least one note is required'); } - // Calculate total available from all notes const totalAvailable = notes.reduce((sum, note) => sum + BigInt(Math.floor(note.assets)), 0n); const totalNeeded = nicksToBigInt(amount) + nicksToBigInt(fee ?? ('0' as Nicks)); - if (totalAvailable < totalNeeded) { throw new Error( `Insufficient funds: have ${totalAvailable} nicks across ${notes.length} notes, need ${totalNeeded}` ); } +} - // Create sender's PKH digest string for change - const senderPKH = publicKeyToPKHDigest(senderPublicKey); - - // Discover the correct spend condition for each note - // Each note may have different spend conditions (e.g., some are coinbase with timelocks) +async function discoverSpendConditions( + notes: Note[], + senderPKH: string, + coinbaseTimelockBlocks?: number +): Promise { const spendConditions: SpendConditionLike[] = []; - for (let i = 0; i < notes.length; i++) { const note = notes[i]; - const spendCondition = await discoverSpendConditionForNote(senderPKH, { - nameFirst: note.nameFirst, - originPage: note.originPage, - }); - - // Sanity check: verify the derived first-name matches + const spendCondition = await discoverSpendConditionForNote( + senderPKH, + { + nameFirst: note.nameFirst, + originPage: note.originPage, + }, + coinbaseTimelockBlocks + ); const derivedFirstName = firstNameFromCondition(spendCondition); if (derivedFirstName !== note.nameFirst) { throw new Error( @@ -330,9 +364,35 @@ export async function buildMultiNotePayment( `Expected: ${note.nameFirst.slice(0, 20)}...` ); } - spendConditions.push(spendCondition); } + return spendConditions; +} + +export async function buildMultiNotePayment( + notes: Note[], + recipientPKH: string, + amount: Nicks, + senderPublicKey: Uint8Array, + privateKey: wasm.PrivateKey, + fee?: Nicks, + refundPKH?: string, + blockHeight?: number, + context?: Pick +): Promise { + // Initialize WASM + await ensureWasmInitialized(); + + validatePaymentCandidates(notes, amount, fee); + + // Create sender's PKH digest string for change + const senderPKH = publicKeyToPKHDigest(senderPublicKey); + + const spendConditions = await discoverSpendConditions( + notes, + senderPKH, + context?.coinbaseTimelockBlocks + ); // Use provided refundPKH or default to sender's PKH // For "send max", refundPKH = recipientPKH to sweep all funds to recipient @@ -350,6 +410,44 @@ export async function buildMultiNotePayment( // include_lock_data: false for lower fees (0.5 NOCK per word saved) includeLockData: false, blockHeight, + txEngineSettings: context?.txEngineSettings, + }); +} + +/** + * Build the exact unsigned payment intent for a wallet account. This deliberately + * accepts the account PKH rather than key material so read-only dApp calls cannot + * derive or access a private key. + */ +export async function buildUnsignedMultiNotePayment( + notes: Note[], + recipientPKH: string, + amount: Nicks, + senderPKH: string, + fee?: Nicks, + refundPKH?: string, + blockHeight?: number, + context?: Pick +): Promise { + await ensureWasmInitialized(); + + validatePaymentCandidates(notes, amount, fee); + const spendConditions = await discoverSpendConditions( + notes, + senderPKH, + context?.coinbaseTimelockBlocks + ); + + return buildUnsignedTransaction({ + notes, + spendCondition: spendConditions, + recipientPKH, + amount, + fee, + refundPKH: refundPKH ?? senderPKH, + includeLockData: false, + blockHeight, + txEngineSettings: context?.txEngineSettings, }); } diff --git a/extension/shared/transaction-fee.test.ts b/extension/shared/transaction-fee.test.ts index 84814aa7..481f3684 100644 --- a/extension/shared/transaction-fee.test.ts +++ b/extension/shared/transaction-fee.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { Nicks } from '@nockbox/iris-sdk/wasm'; import { + assertMatchingTransactionIntent, buildWithAdvisoryFeeRetry, + resolveBuilderFeeSummary, resolveBuiltInputSelection, resolveBuiltTransactionAmounts, resolveTransactionFeeForBuild, @@ -10,6 +12,28 @@ import { const nicks = (value: string) => value as Nicks; describe('transaction fee contract', () => { + it('preserves the approved witnessless intent through signing', () => { + expect(() => + assertMatchingTransactionIntent('approved-spends', 'approved-spends') + ).not.toThrow(); + expect(() => assertMatchingTransactionIntent('approved-spends', 'changed-spends')).toThrow( + 'intent changed' + ); + }); + + it('reports an explicit fee above the minimum as the actual transaction fee', () => { + expect(resolveBuilderFeeSummary(nicks('900'), nicks('700'), true)).toEqual({ + fee: 900, + minimumFee: 700, + }); + }); + + it('rejects an explicit fee below the calculated minimum', () => { + expect(() => resolveBuilderFeeSummary(nicks('600'), nicks('700'), true)).toThrow( + 'below the minimum' + ); + }); + it('keeps an explicit dApp fee as the exact WASM override', () => { expect(resolveTransactionFeeForBuild(nicks('500'), false)).toEqual({ fee: nicks('500'), @@ -114,16 +138,22 @@ describe('transaction fee contract', () => { }); it('reconciles reserved candidates to the inputs actually built by WASM', () => { - expect( - resolveBuiltInputSelection( - [ - { noteId: 'a', assets: 7_000 }, - { noteId: 'b', assets: 3_000 }, - { noteId: 'unused', assets: 1_000 }, - ], - ['a', 'b'] - ) - ).toEqual({ inputNoteIds: ['a', 'b'], selectedTotal: 10_000 }); + const candidates = [ + { noteId: 'a', assets: 7_000, state: 'available' }, + { noteId: 'b', assets: 3_000, state: 'available' }, + { noteId: 'unused', assets: 1_000, state: 'available' }, + ] as const; + + expect(resolveBuiltInputSelection(candidates, ['a', 'b'])).toEqual({ + inputNoteIds: ['a', 'b'], + selectedTotal: 10_000, + }); + // Unsigned build reconciliation is read-only: selection alone never reserves notes. + expect(candidates.map(candidate => candidate.state)).toEqual([ + 'available', + 'available', + 'available', + ]); }); it('rejects a built input that was not locally reserved', () => { diff --git a/extension/shared/transaction-fee.ts b/extension/shared/transaction-fee.ts index 40d737da..047b5f14 100644 --- a/extension/shared/transaction-fee.ts +++ b/extension/shared/transaction-fee.ts @@ -7,6 +7,34 @@ export type TransactionFeeBuildOptions = { feeSelectionHint?: Nicks; }; +/** Keep the fee assigned to the transaction distinct from the minimum required fee. */ +export function resolveBuilderFeeSummary( + currentFee: Nicks | number, + minimumFee: Nicks | number, + hasExplicitFee: boolean +): { fee: number; minimumFee: number } { + const fee = Number(currentFee); + const minimum = Number(minimumFee); + if (!Number.isSafeInteger(fee) || fee < 0 || !Number.isSafeInteger(minimum) || minimum < 0) { + throw new Error('Transaction fee exceeds the supported Nicks range'); + } + if (fee < minimum) { + throw new Error( + hasExplicitFee + ? `Explicit fee is below the minimum required fee of ${minimum} nicks` + : `Calculated fee is below the minimum required fee of ${minimum} nicks` + ); + } + return { fee, minimumFee: minimum }; +} + +/** Reject signing/broadcast if witness insertion changed the approved spends intent. */ +export function assertMatchingTransactionIntent(expected: string, actual: string): void { + if (!expected || expected !== actual) { + throw new Error('Transaction intent changed after approval'); + } +} + /** * Preserve the distinction between a dApp fee override and a wallet-generated estimate. * Requests created before `feeEstimated` existed are treated as explicit-fee requests. diff --git a/extension/shared/types.ts b/extension/shared/types.ts index fab9b339..eab7a1a7 100644 --- a/extension/shared/types.ts +++ b/extension/shared/types.ts @@ -3,7 +3,13 @@ */ import { PROVIDER_METHODS, INTERNAL_METHODS, RPC_METHODS, ERROR_CODES } from './constants'; -import type { Nicks } from '@nockbox/iris-sdk/wasm'; +import type { + Digest, + Nicks, + NockchainTx, + Note as NativeNote, + TxEngineSettings, +} from '@nockbox/iris-sdk/wasm'; /** * Child wallet account, always nested under a SeedAccount. @@ -171,12 +177,57 @@ export interface TransactionRequest { * on approval the exact fee is auto-calculated by WASM at build time. */ feeEstimated?: boolean; + /** + * Exact unsigned intent built before approval. Optional so transaction + * approvals created by older extension versions remain readable. + */ + builtTransaction?: BuiltSimpleTransaction; + /** + * Wallet-internal context bound to `builtTransaction`. Optional for approval + * snapshots created by older extension versions; never exposed by the public + * build RPC. + */ + transactionContext?: TransactionApprovalContext; /** Account selected when the transaction request was created. */ accountAddress: string; /** Request timestamp */ timestamp: number; } +/** Internal build/sign/broadcast context persisted with an exact approval. */ +export interface TransactionApprovalContext { + fingerprint: string; + /** Endpoint/network identity of the account data used by this build. */ + networkIdentity: string; + rpcUrl: string; + networkName: string; + coinbaseTimelockBlocks: number; + txEngineActivationHeight: number; + nextTxEngineActivationHeight?: number; + txEngineSettings: TxEngineSettings; +} + +/** + * Canonical result of nock_buildSimpleTransaction. Native notes are sidecars: + * signers must resolve transaction input names against their own trusted store. + */ +export interface BuiltSimpleTransaction { + tx: NockchainTx; + /** Exact native inputs committed by tx, ordered by the transaction's input names. */ + notes: NativeNote[]; + /** Output notes projected from tx's seeds using `blockHeight` and its engine settings. */ + outputs: NativeNote[]; + intentId: Digest; + accountAddress: Digest; + blockHeight: number; + to: Digest; + amount: Nicks; + inputTotal: Nicks; + fee: Nicks; + minimumFee: Nicks; + change: Nicks; +} + /** Pending signTx approval request stored in native wasm form. */ export interface SignRawTxRequest { rawTx: unknown; // wasm.RawTx (native) @@ -196,6 +247,8 @@ export interface SignRawTxRequest { reviewBlockHeight: number; /** Account whose available notes were used to verify the transaction inputs. */ accountAddress: string; + /** Internal network/engine context used to render and later sign the review. */ + transactionContext?: TransactionApprovalContext; id: string; origin: string; outputs?: unknown[]; @@ -367,6 +420,11 @@ export interface WalletTransaction { /** Tx id used for Nockblocks tracking when available */ trackingTxId?: string; + /** Reviewed spends intent for the exact pre-approval send path. */ + exactIntentId?: string; + /** Wallet-owned local flow with a durable pre-broadcast recovery boundary. */ + locallyManagedSubmission?: boolean; + // For outgoing transactions /** Note IDs used as inputs (spent) */ inputNoteIds?: string[]; @@ -424,6 +482,12 @@ export interface WalletTxStore { */ export interface AccountSyncState { accountAddress: string; + /** Endpoint/network identity that produced the cached account UTXOs. */ + rpcNetworkIdentity?: string; + /** A persisted sync began mutating this snapshot but did not commit a new tip. */ + utxoSyncInProgress?: boolean; + /** Terminal history predating a network switch must not classify new-chain change. */ + excludeTerminalHistoryFromChangeDetection?: boolean; /** Last block height that was fully synced (inclusive) */ lastSyncedHeight: number; /** Timestamp of last successful sync */ diff --git a/extension/shared/utxo-utils.test.ts b/extension/shared/utxo-utils.test.ts new file mode 100644 index 00000000..01d3b1bd --- /dev/null +++ b/extension/shared/utxo-utils.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest'; +import type { StoredNote } from './types'; +import type { WalletTransaction } from './types'; +import { + releaseOwnedNoteReservations, + releaseUnownedOnChainReservations, + recoverInterruptedExactReservations, + stageAdditionalTransactionReservation, + reserveAvailableNotes, + stageExactTransactionReservation, +} from './utxo-utils'; + +const ACCOUNT = 'account-a'; + +function storedNote( + noteId: string, + state: StoredNote['state'] = 'available', + pendingTxId?: string, + accountAddress = ACCOUNT +): StoredNote { + return { + noteId, + accountAddress, + sourceHash: `source-${noteId}`, + originPage: 1, + assets: 100, + nameFirst: `first-${noteId}`, + nameLast: `last-${noteId}`, + noteDataHashBase58: `data-${noteId}`, + protoNote: {}, + state, + pendingTxId, + discoveredAt: 1, + }; +} + +function walletTransaction( + id: string, + inputNoteIds: string[], + status: WalletTransaction['status'] = 'created' +): WalletTransaction { + return { + id, + accountAddress: ACCOUNT, + direction: 'outgoing', + createdAt: 1, + updatedAt: 1, + status, + inputNoteIds, + }; +} + +describe('exact note reservations', () => { + it('preflights every input before mutating any note', () => { + const available = storedNote('available'); + const alreadyReserved = storedNote('reserved', 'in_flight', 'other-transaction'); + const notes = [available, alreadyReserved]; + + expect(() => + reserveAvailableNotes(notes, ACCOUNT, ['available', 'reserved'], 'new-transaction') + ).toThrow('state is in_flight'); + + expect(available).toMatchObject({ state: 'available', pendingTxId: undefined }); + expect(alreadyReserved).toMatchObject({ + state: 'in_flight', + pendingTxId: 'other-transaction', + }); + }); + + it('rejects foreign and duplicate inputs without partial reservation', () => { + const local = storedNote('local'); + const foreign = storedNote('foreign', 'available', undefined, 'account-b'); + + expect(() => + reserveAvailableNotes([local, foreign], ACCOUNT, ['local', 'foreign'], 'new-transaction') + ).toThrow('not owned'); + expect(local.state).toBe('available'); + + expect(() => + reserveAvailableNotes([local], ACCOUNT, ['local', 'local'], 'new-transaction') + ).toThrow('duplicates'); + expect(local.state).toBe('available'); + }); + + it('releases only reservations owned by the failed transaction', () => { + const ours = storedNote('ours', 'in_flight', 'new-transaction'); + const theirs = storedNote('theirs', 'in_flight', 'other-transaction'); + + expect( + releaseOwnedNoteReservations([ours, theirs], ACCOUNT, ['ours', 'theirs'], 'new-transaction') + ).toBe(1); + expect(ours.state).toBe('available'); + expect(ours.pendingTxId).toBeUndefined(); + expect(theirs).toMatchObject({ state: 'in_flight', pendingTxId: 'other-transaction' }); + }); + + it('stages reservation and history together and rolls back only its own mutation', () => { + const note = storedNote('input'); + const concurrentHistory = walletTransaction('existing', []); + const transactions = [concurrentHistory]; + const pending = walletTransaction('new-transaction', ['input']); + + const staged = stageExactTransactionReservation( + [note], + transactions, + ACCOUNT, + ['input'], + pending + ); + expect(note).toMatchObject({ state: 'in_flight', pendingTxId: 'new-transaction' }); + expect(transactions.map(transaction => transaction.id)).toEqual([ + 'new-transaction', + 'existing', + ]); + + // A concurrent history append must survive rollback of the staged send. + transactions.push(walletTransaction('concurrent', [])); + staged.rollback(); + expect(note.state).toBe('available'); + expect(note.pendingTxId).toBeUndefined(); + expect(transactions.map(transaction => transaction.id)).toEqual(['existing', 'concurrent']); + }); + + it('releases only unowned reservations confirmed to remain on chain', () => { + const active = storedNote('active', 'in_flight', 'active-transaction'); + const missingHistory = storedNote('missing', 'in_flight', 'missing-transaction'); + const terminal = storedNote('terminal', 'in_flight', 'failed-transaction'); + const offChain = storedNote('off-chain', 'in_flight', 'missing-transaction'); + const transactions = [ + walletTransaction('active-transaction', ['active']), + walletTransaction('failed-transaction', ['terminal'], 'failed'), + ]; + + expect( + releaseUnownedOnChainReservations( + [active, missingHistory, terminal, offChain], + transactions, + ACCOUNT, + new Set(['active', 'missing', 'terminal']) + ) + ).toBe(2); + expect(active).toMatchObject({ state: 'in_flight', pendingTxId: 'active-transaction' }); + expect(missingHistory.state).toBe('available'); + expect(terminal.state).toBe('available'); + expect(offChain).toMatchObject({ state: 'in_flight', pendingTxId: 'missing-transaction' }); + }); + + it('keeps reservations owned by an incomplete legacy pending record', () => { + const note = storedNote('legacy', 'in_flight', 'legacy-transaction'); + const legacy = walletTransaction('legacy-transaction', []); + + expect(releaseUnownedOnChainReservations([note], [legacy], ACCOUNT, new Set(['legacy']))).toBe( + 0 + ); + expect(note.state).toBe('in_flight'); + }); + + it('fails and releases an exact send interrupted before tx-id persistence', () => { + const note = storedNote('input', 'in_flight', 'interrupted'); + const interrupted = { + ...walletTransaction('interrupted', ['input']), + exactIntentId: 'approved-intent', + }; + const ambiguous = { + ...walletTransaction('ambiguous', ['other']), + exactIntentId: 'approved-intent', + txHash: 'signed-transaction-id', + }; + const broadcastPending = { + ...walletTransaction('broadcast-pending', ['other'], 'broadcast_pending'), + exactIntentId: 'approved-intent', + }; + + expect( + recoverInterruptedExactReservations( + [note], + [interrupted, ambiguous, broadcastPending], + ACCOUNT, + 123 + ) + ).toEqual({ released: 1, failed: 1 }); + expect(note.state).toBe('available'); + expect(note.pendingTxId).toBeUndefined(); + expect(interrupted).toMatchObject({ status: 'failed', updatedAt: 123 }); + expect(ambiguous.status).toBe('created'); + expect(broadcastPending.status).toBe('broadcast_pending'); + }); + + it('never releases a reservation owned by a different transaction during recovery', () => { + const note = storedNote('input', 'in_flight', 'other-owner'); + const interrupted = { + ...walletTransaction('interrupted', ['input']), + exactIntentId: 'approved-intent', + }; + + expect(recoverInterruptedExactReservations([note], [interrupted], ACCOUNT, 123)).toEqual({ + released: 0, + failed: 1, + }); + expect(note).toMatchObject({ state: 'in_flight', pendingTxId: 'other-owner' }); + }); + + it('recovers an interrupted wallet-managed popup or bridge submission', () => { + const note = storedNote('input', 'in_flight', 'managed'); + const managed = { + ...walletTransaction('managed', ['input']), + locallyManagedSubmission: true, + }; + + expect(recoverInterruptedExactReservations([note], [managed], ACCOUNT, 123)).toEqual({ + released: 1, + failed: 1, + }); + expect(note).toMatchObject({ state: 'available' }); + expect(managed).toMatchObject({ status: 'failed', updatedAt: 123 }); + }); + + it('stages retry reservations and owner history together with targeted rollback', () => { + const original = storedNote('original'); + original.state = 'in_flight'; + original.pendingTxId = 'managed'; + const additional = storedNote('additional'); + const unrelated = storedNote('unrelated', 'in_flight', 'other'); + const transaction = { + ...walletTransaction('managed', ['original']), + locallyManagedSubmission: true, + }; + + const staged = stageAdditionalTransactionReservation( + [original, additional, unrelated], + transaction, + ACCOUNT, + ['additional'] + ); + expect(additional).toMatchObject({ state: 'in_flight', pendingTxId: 'managed' }); + expect(transaction.inputNoteIds).toEqual(['original', 'additional']); + + staged.rollback(); + expect(additional).toMatchObject({ state: 'available' }); + expect(additional.pendingTxId).toBeUndefined(); + expect(transaction.inputNoteIds).toEqual(['original']); + expect(unrelated).toMatchObject({ state: 'in_flight', pendingTxId: 'other' }); + }); +}); diff --git a/extension/shared/utxo-utils.ts b/extension/shared/utxo-utils.ts index 9a79b257..cce7cc68 100644 --- a/extension/shared/utxo-utils.ts +++ b/extension/shared/utxo-utils.ts @@ -2,7 +2,7 @@ * UTXO utils */ -import type { StoredNote, NoteState, FetchedUTXO, Note } from './types'; +import type { StoredNote, NoteState, FetchedUTXO, Note, WalletTransaction } from './types'; import { base58 } from '@scure/base'; // ============================================================================ @@ -48,6 +48,203 @@ export function generateNoteId(nameFirst: string, nameLast: string): string { return `${nameFirst}:${nameLast}`; } +/** + * Atomically reserve an exact input set. Every input is preflighted before any + * note is mutated, preventing a stale input from leaving a partial reservation. + */ +export function reserveAvailableNotes( + notes: StoredNote[], + accountAddress: string, + noteIds: readonly string[], + walletTxId: string +): void { + const uniqueNoteIds = [...new Set(noteIds)]; + if (uniqueNoteIds.length === 0 || uniqueNoteIds.length !== noteIds.length) { + throw new Error('Transaction input set is empty or contains duplicates'); + } + + const notesById = new Map(notes.map(note => [note.noteId, note])); + const toReserve = uniqueNoteIds.map(noteId => { + const note = notesById.get(noteId); + if (!note || note.accountAddress !== accountAddress) { + throw new Error(`Cannot reserve note ${noteId}: input is not owned by the selected account`); + } + if (note.state !== 'available') { + throw new Error(`Cannot reserve note ${noteId}: state is ${note.state}`); + } + return note; + }); + + for (const note of toReserve) { + note.state = 'in_flight'; + note.pendingTxId = walletTxId; + } +} + +/** Release only reservations owned by the transaction being cleaned up. */ +export function releaseOwnedNoteReservations( + notes: StoredNote[], + accountAddress: string, + noteIds: readonly string[], + walletTxId: string +): number { + const noteIdSet = new Set(noteIds); + let released = 0; + for (const note of notes) { + if ( + noteIdSet.has(note.noteId) && + note.accountAddress === accountAddress && + note.state === 'in_flight' && + note.pendingTxId === walletTxId + ) { + note.state = 'available'; + delete note.pendingTxId; + released += 1; + } + } + return released; +} + +/** + * Stage one exact-send reservation and its history record as a single in-memory + * change. The returned rollback restores both collections if their one durable + * snapshot fails to persist. + */ +export function stageExactTransactionReservation( + notes: StoredNote[], + transactions: WalletTransaction[], + accountAddress: string, + noteIds: readonly string[], + walletTx: WalletTransaction +): { rollback: () => void } { + if (walletTx.accountAddress !== accountAddress || walletTx.direction !== 'outgoing') { + throw new Error('Transaction reservation account does not match its history record'); + } + if (transactions.some(transaction => transaction.id === walletTx.id)) { + throw new Error(`Transaction ${walletTx.id} already exists`); + } + + reserveAvailableNotes(notes, accountAddress, noteIds, walletTx.id); + transactions.unshift(walletTx); + + let active = true; + return { + rollback: () => { + if (!active) return; + active = false; + releaseOwnedNoteReservations(notes, accountAddress, noteIds, walletTx.id); + const stagedIndex = transactions.findIndex( + transaction => transaction === walletTx || transaction.id === walletTx.id + ); + if (stagedIndex !== -1) { + transactions.splice(stagedIndex, 1); + } + }, + }; +} + +/** Stage additional inputs for an existing wallet-owned submission. */ +export function stageAdditionalTransactionReservation( + notes: StoredNote[], + transaction: WalletTransaction, + accountAddress: string, + noteIds: readonly string[] +): { rollback: () => void } { + const previousInputNoteIds = transaction.inputNoteIds ? [...transaction.inputNoteIds] : undefined; + reserveAvailableNotes(notes, accountAddress, noteIds, transaction.id); + transaction.inputNoteIds = [...new Set([...(transaction.inputNoteIds ?? []), ...noteIds])]; + + return { + rollback: () => { + releaseOwnedNoteReservations(notes, accountAddress, noteIds, transaction.id); + transaction.inputNoteIds = previousInputNoteIds; + }, + }; +} + +const ACTIVE_RESERVATION_STATUSES = new Set([ + 'created', + 'broadcast_pending', + 'mempool_seen', + 'broadcasted_unconfirmed', +]); + +/** + * Recover legacy/crash-gap reservations only after a successful chain query + * confirms the input is still unspent. Active transaction owners are retained. + */ +export function releaseUnownedOnChainReservations( + notes: StoredNote[], + transactions: readonly WalletTransaction[], + accountAddress: string, + onChainNoteIds: ReadonlySet +): number { + const activeReservationOwners = new Set(); + for (const transaction of transactions) { + if ( + transaction.accountAddress === accountAddress && + transaction.direction === 'outgoing' && + ACTIVE_RESERVATION_STATUSES.has(transaction.status) + ) { + activeReservationOwners.add(transaction.id); + } + } + + let released = 0; + for (const note of notes) { + if ( + note.accountAddress !== accountAddress || + note.state !== 'in_flight' || + !onChainNoteIds.has(note.noteId) + ) { + continue; + } + if (note.pendingTxId && activeReservationOwners.has(note.pendingTxId)) continue; + + note.state = 'available'; + delete note.pendingTxId; + released += 1; + } + return released; +} + +/** + * An exact-send record still in `created` with no chain id after a process + * restart cannot have crossed this path's pre-broadcast persistence boundary. + */ +export function recoverInterruptedExactReservations( + notes: StoredNote[], + transactions: WalletTransaction[], + accountAddress: string, + now = Date.now() +): { released: number; failed: number } { + let released = 0; + let failed = 0; + for (const transaction of transactions) { + if ( + transaction.accountAddress !== accountAddress || + transaction.direction !== 'outgoing' || + transaction.status !== 'created' || + (!transaction.exactIntentId && !transaction.locallyManagedSubmission) || + transaction.txHash || + transaction.trackingTxId + ) { + continue; + } + + released += releaseOwnedNoteReservations( + notes, + accountAddress, + transaction.inputNoteIds ?? [], + transaction.id + ); + transaction.status = 'failed'; + transaction.updatedAt = now; + failed += 1; + } + return { released, failed }; +} + /** * Convert Uint8Array to base58 string */ diff --git a/extension/shared/vault-lifecycle.test.ts b/extension/shared/vault-lifecycle.test.ts new file mode 100644 index 00000000..888e8bfa --- /dev/null +++ b/extension/shared/vault-lifecycle.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const cryptoMocks = vi.hoisted(() => ({ + decryptGCM: vi.fn(), + deriveKeyPBKDF2: vi.fn(async () => ({ key: {} as CryptoKey, salt: new Uint8Array() })), + encryptGCM: vi.fn(async () => ({ iv: new Uint8Array(), ct: new Uint8Array() })), +})); + +vi.mock('./webcrypto', () => ({ + PBKDF2_ITERATIONS: 1, + rand: (length: number) => new Uint8Array(length), + deriveKeyPBKDF2: cryptoMocks.deriveKeyPBKDF2, + decryptGCM: cryptoMocks.decryptGCM, + encryptGCM: cryptoMocks.encryptGCM, +})); + +vi.mock('./wallet-crypto', () => ({ + generateMnemonic: vi.fn(() => 'generated mnemonic'), + deriveAddress: vi.fn(async () => 'account'), + deriveAddressFromMaster: vi.fn(async () => 'account'), + validateMnemonic: vi.fn(() => true), +})); + +vi.mock('@nockbox/iris-sdk', () => ({ + PROVIDER_METHODS: {}, + DEFAULT_COINBASE_TIMELOCK_BLOCKS: 100, + DEFAULT_TX_ENGINE_ACTIVATION_HEIGHTS: {}, + MIN_BRIDGE_AMOUNT_NOCK: 1, + NOCK_TO_NICKS: 1, + ZORP_BRIDGE_ADDRESSES: [], + ZORP_BRIDGE_THRESHOLD: 1, + buildBridgeTransaction: vi.fn(), + validateBridgeTransaction: vi.fn(), +})); + +vi.mock('@nockbox/iris-sdk/wasm', () => ({ guard: {} })); +vi.mock('./sdk-wasm.js', () => ({ default: {}, initWasm: vi.fn(), wasm: {} })); + +import { Vault } from './vault'; + +const ENCRYPTED_VAULT = { + version: 1, + kdf: { name: 'PBKDF2', hash: 'SHA-256', iterations: 1, salt: [] }, + cipher: { alg: 'AES-GCM', iv: [], ct: [] }, +}; +const DECRYPTED_VAULT = JSON.stringify({ + version: 2, + seedAccounts: [ + { + id: 'seed', + name: 'Wallet 1', + type: 'mnemonic', + mnemonic: 'test mnemonic', + createdAt: 1, + accounts: [{ name: 'Wallet 1', address: 'account', index: 0 }], + }, + ], +}); +const DECRYPTED_LEGACY_VAULT = JSON.stringify({ + mnemonic: 'test mnemonic', + accounts: [{ name: 'Wallet 1', address: 'account', index: 0 }], +}); + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise(done => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('Vault lifecycle ordering', () => { + beforeEach(() => { + cryptoMocks.decryptGCM.mockReset(); + cryptoMocks.deriveKeyPBKDF2.mockClear(); + cryptoMocks.encryptGCM.mockReset(); + cryptoMocks.encryptGCM.mockResolvedValue({ iv: new Uint8Array(), ct: new Uint8Array() }); + vi.stubGlobal('chrome', { + storage: { + local: { + get: vi.fn(async () => ({ enc: ENCRYPTED_VAULT })), + set: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + }, + }, + }); + }); + + it('does not let a slow password unlock undo a later lock', async () => { + const decrypted = deferred(); + cryptoMocks.decryptGCM.mockReturnValueOnce(decrypted.promise); + const vault = new Vault(); + + const unlock = vault.unlock('password'); + await vi.waitFor(() => expect(cryptoMocks.decryptGCM).toHaveBeenCalledOnce()); + await vault.lock(); + decrypted.resolve(DECRYPTED_VAULT); + + await expect(unlock).resolves.toMatchObject({ error: expect.anything() }); + expect(vault.isLocked()).toBe(true); + expect(vault.getCurrentAccount()).toBeNull(); + }); + + it('does not let a slow cached-key unlock undo a later reset', async () => { + const decrypted = deferred(); + cryptoMocks.decryptGCM.mockReturnValueOnce(decrypted.promise); + const vault = new Vault(); + + const unlock = vault.unlockWithKey({} as CryptoKey); + await vi.waitFor(() => expect(cryptoMocks.decryptGCM).toHaveBeenCalledOnce()); + await vault.reset(); + decrypted.resolve(DECRYPTED_VAULT); + + await expect(unlock).resolves.toMatchObject({ error: expect.anything() }); + expect(vault.isLocked()).toBe(true); + expect(vault.getCurrentAccount()).toBeNull(); + expect(chrome.storage.local.clear).toHaveBeenCalledOnce(); + }); + + it('admits only one password or cached-key unlock attempt at a time', async () => { + const decrypted = deferred(); + cryptoMocks.decryptGCM.mockReturnValueOnce(decrypted.promise); + const vault = new Vault(); + + const firstUnlock = vault.unlock('password'); + await vi.waitFor(() => expect(cryptoMocks.decryptGCM).toHaveBeenCalledOnce()); + const overlappingUnlock = vault.unlockWithKey({} as CryptoKey); + + await expect(overlappingUnlock).resolves.toMatchObject({ error: expect.anything() }); + expect(cryptoMocks.decryptGCM).toHaveBeenCalledOnce(); + + decrypted.resolve(DECRYPTED_VAULT); + await expect(firstUnlock).resolves.toMatchObject({ ok: true, address: 'account' }); + expect(vault.isLocked()).toBe(false); + expect(vault.getCurrentAccount()?.address).toBe('account'); + }); + + it('keeps provisional decrypted metadata private until migration persistence finishes', async () => { + cryptoMocks.decryptGCM.mockResolvedValueOnce(DECRYPTED_LEGACY_VAULT); + const encrypted = deferred<{ + iv: Uint8Array; + ct: Uint8Array; + }>(); + cryptoMocks.encryptGCM.mockReturnValueOnce(encrypted.promise); + const vault = new Vault(); + + const unlock = vault.unlock('password'); + await vi.waitFor(() => expect(cryptoMocks.encryptGCM).toHaveBeenCalledOnce()); + + expect(vault.isLocked()).toBe(true); + expect(vault.getCurrentAccount()).toBeNull(); + expect(vault.getAccounts()).toEqual([]); + expect(vault.getSeedSources()).toEqual([]); + await expect(vault.getAddressSafe()).resolves.toBe(''); + + encrypted.resolve({ iv: new Uint8Array(), ct: new Uint8Array() }); + await expect(unlock).resolves.toMatchObject({ ok: true, address: 'account' }); + expect(vault.getCurrentAccount()?.address).toBe('account'); + }); + + it('treats an unlock request as idempotent after session restore already unlocked', async () => { + cryptoMocks.decryptGCM.mockResolvedValueOnce(DECRYPTED_VAULT); + const vault = new Vault(); + + await expect(vault.unlockWithKey({} as CryptoKey)).resolves.toMatchObject({ + ok: true, + address: 'account', + }); + await expect(vault.unlock('password')).resolves.toMatchObject({ + ok: true, + address: 'account', + }); + expect(cryptoMocks.decryptGCM).toHaveBeenCalledOnce(); + }); + + it('orders a slow encrypted-vault header save before reset clears storage', async () => { + cryptoMocks.decryptGCM.mockResolvedValueOnce(DECRYPTED_VAULT); + const vault = new Vault(); + await expect(vault.unlock('password')).resolves.toMatchObject({ ok: true }); + + const encrypted = deferred<{ + iv: Uint8Array; + ct: Uint8Array; + }>(); + cryptoMocks.encryptGCM.mockReturnValueOnce(encrypted.promise); + const rename = vault.renameAccount('account', 'Renamed'); + await vi.waitFor(() => expect(cryptoMocks.encryptGCM).toHaveBeenCalledOnce()); + + const reset = vault.reset(); + encrypted.resolve({ iv: new Uint8Array(), ct: new Uint8Array() }); + await expect(rename).rejects.toThrow(/lifecycle changed/i); + await expect(reset).resolves.toEqual({ ok: true }); + + expect(chrome.storage.local.clear).toHaveBeenCalledOnce(); + const vaultHeaderWrites = vi + .mocked(chrome.storage.local.set) + .mock.calls.filter(([value]) => Object.prototype.hasOwnProperty.call(value, 'enc')); + expect(vaultHeaderWrites).toHaveLength(0); + }); + + it('does not let a slow setup publish after a later lock', async () => { + const encrypted = deferred<{ + iv: Uint8Array; + ct: Uint8Array; + }>(); + cryptoMocks.encryptGCM.mockReturnValueOnce(encrypted.promise); + const vault = new Vault(); + + const setup = vault.setup('password'); + await vi.waitFor(() => expect(cryptoMocks.encryptGCM).toHaveBeenCalledOnce()); + await vault.lock(); + encrypted.resolve({ iv: new Uint8Array(), ct: new Uint8Array() }); + + await expect(setup).resolves.toEqual({ error: expect.anything() }); + expect(vault.isLocked()).toBe(true); + expect(vault.getAccounts()).toEqual([]); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + }); + + it('does not let a slow setup recreate storage after reset', async () => { + const encrypted = deferred<{ + iv: Uint8Array; + ct: Uint8Array; + }>(); + cryptoMocks.encryptGCM.mockReturnValueOnce(encrypted.promise); + const vault = new Vault(); + + const setup = vault.setup('password'); + await vi.waitFor(() => expect(cryptoMocks.encryptGCM).toHaveBeenCalledOnce()); + await vault.reset(); + encrypted.resolve({ iv: new Uint8Array(), ct: new Uint8Array() }); + + await expect(setup).resolves.toEqual({ error: expect.anything() }); + expect(chrome.storage.local.clear).toHaveBeenCalledOnce(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/shared/vault-sync-provenance.test.ts b/extension/shared/vault-sync-provenance.test.ts new file mode 100644 index 00000000..679dc13c --- /dev/null +++ b/extension/shared/vault-sync-provenance.test.ts @@ -0,0 +1,281 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StoredNote, WalletTransaction } from './types'; + +const mocks = vi.hoisted(() => ({ + queryV1Balance: vi.fn(), + encryptGCM: vi.fn(async () => ({ iv: new Uint8Array(), ct: new Uint8Array() })), + rpcConfig: undefined as Record | undefined, +})); + +vi.mock('./balance-query', () => ({ queryV1Balance: mocks.queryV1Balance })); +vi.mock('./rpc-client-browser', () => ({ createBrowserClient: vi.fn(() => ({})) })); +vi.mock('./nockblocks-client.js', () => ({ + isNockblocksConfigured: vi.fn(() => false), + createNockblocksClient: vi.fn(), +})); +vi.mock('./webcrypto', () => ({ + PBKDF2_ITERATIONS: 1, + rand: (length: number) => new Uint8Array(length), + deriveKeyPBKDF2: vi.fn(), + decryptGCM: vi.fn(), + encryptGCM: mocks.encryptGCM, +})); +vi.mock('@nockbox/iris-sdk', () => ({ + PROVIDER_METHODS: {}, + DEFAULT_COINBASE_TIMELOCK_BLOCKS: 100, + DEFAULT_TX_ENGINE_ACTIVATION_HEIGHTS: {}, + MIN_BRIDGE_AMOUNT_NOCK: 1, + NOCK_TO_NICKS: 1, + ZORP_BRIDGE_ADDRESSES: [], + ZORP_BRIDGE_THRESHOLD: 1, + buildBridgeTransaction: vi.fn(), + validateBridgeTransaction: vi.fn(), +})); +vi.mock('@nockbox/iris-sdk/wasm', () => ({ guard: {} })); +vi.mock('./sdk-wasm.js', () => ({ default: {}, initWasm: vi.fn(), wasm: {} })); + +import { Vault } from './vault'; +import { defaultRpcConfig, getRpcNetworkIdentity } from './rpc-config'; +import { generateNoteId } from './utxo-utils'; + +const ACCOUNT = 'account'; +const FIRST = 'first'; +const LAST = 'last'; +const NOTE_ID = generateNoteId(FIRST, LAST); + +function storedNote(state: StoredNote['state'] = 'available', pendingTxId?: string): StoredNote { + return { + noteId: NOTE_ID, + accountAddress: ACCOUNT, + sourceHash: 'source', + originPage: 1, + assets: 100, + nameFirst: FIRST, + nameLast: LAST, + noteDataHashBase58: 'data', + protoNote: {}, + state, + pendingTxId, + discoveredAt: 1, + }; +} + +function balance(blockHeight: number, nameFirst = FIRST, nameLast = LAST) { + return { + blockHeight, + totalNock: 100, + utxoCount: 1, + simpleNotes: [ + { + nameFirstBase58: nameFirst, + nameLastBase58: nameLast, + nameFirst: new Uint8Array(), + nameLast: new Uint8Array(), + sourceHash: new Uint8Array(), + originPage: 1n, + assets: 100, + noteDataHashBase58: 'data', + protoNote: {}, + }, + ], + coinbaseNotes: [], + }; +} + +function transaction( + id: string, + status: WalletTransaction['status'], + inputNoteIds: string[] = [] +): WalletTransaction { + return { + id, + accountAddress: ACCOUNT, + direction: 'outgoing', + createdAt: Date.now(), + updatedAt: Date.now(), + status, + inputNoteIds, + amount: 10, + fee: 1, + expectedChange: 89, + }; +} + +type VaultInternals = { + state: { + locked: boolean; + accounts: Array<{ name: string; address: string; index: number }>; + currentAccountIndex: number; + enc: null; + }; + encryptionKey: CryptoKey | null; + utxoStore: Record; + walletTxStore: Record; + accountSyncState: Record>; + cachedBalances: Record; +}; + +function unlockedVault(options: { + note?: StoredNote; + transactions?: WalletTransaction[]; + rpcNetworkIdentity?: string; + blockHeight?: number; +}): Vault { + const vault = new Vault(); + const internals = vault as unknown as VaultInternals; + internals.state = { + locked: false, + accounts: [{ name: 'Wallet', address: ACCOUNT, index: 0 }], + currentAccountIndex: 0, + enc: null, + }; + internals.encryptionKey = {} as CryptoKey; + internals.utxoStore = { + [ACCOUNT]: { + notes: options.note ? [options.note] : [], + version: 1, + blockHeight: options.blockHeight ?? 50, + }, + }; + internals.walletTxStore = { [ACCOUNT]: options.transactions ?? [] }; + internals.accountSyncState = { + [ACCOUNT]: { + accountAddress: ACCOUNT, + lastSyncedHeight: options.blockHeight ?? 50, + lastSyncedAt: 1, + rpcNetworkIdentity: options.rpcNetworkIdentity, + }, + }; + internals.cachedBalances = { [ACCOUNT]: 100 }; + return vault; +} + +describe('Vault UTXO network provenance', () => { + beforeEach(() => { + mocks.rpcConfig = undefined; + mocks.queryV1Balance.mockReset(); + mocks.encryptGCM.mockReset(); + mocks.encryptGCM.mockResolvedValue({ iv: new Uint8Array(), ct: new Uint8Array() }); + vi.stubGlobal('chrome', { + storage: { + local: { + get: vi.fn((_: unknown, callback?: (value: unknown) => void) => { + const value = mocks.rpcConfig ? { rpcConfig: mocks.rpcConfig } : {}; + if (callback) { + callback(value); + return undefined; + } + return Promise.resolve(value); + }), + set: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + }, + }, + }); + }); + + it('advances the coherent UTXO tip even when the note set is unchanged', async () => { + const identity = getRpcNetworkIdentity(defaultRpcConfig); + const vault = unlockedVault({ + note: storedNote(), + rpcNetworkIdentity: identity, + blockHeight: 50, + }); + mocks.queryV1Balance.mockResolvedValue(balance(100)); + + await vault.syncAccountUTXOs(ACCOUNT, { skipHistory: true }); + + expect(vault.getAccountBlockHeight(ACCOUNT)).toBe(100); + expect(vault.getAccountSyncState(ACCOUNT)).toMatchObject({ + rpcNetworkIdentity: identity, + utxoSyncInProgress: false, + lastSyncedHeight: 100, + }); + }); + + it('adopts a legacy default-network cache without abandoning its pending transaction', async () => { + const pending = transaction('pending', 'created', [NOTE_ID]); + const vault = unlockedVault({ + note: storedNote('in_flight', pending.id), + transactions: [pending], + }); + mocks.queryV1Balance.mockResolvedValue(balance(100)); + + await vault.syncAccountUTXOs(ACCOUNT, { skipHistory: true }); + + expect(pending).toMatchObject({ status: 'created', expectedChange: 89 }); + expect(vault.getAccountNotes(ACCOUNT)[0]).toMatchObject({ + state: 'in_flight', + pendingTxId: pending.id, + }); + expect(vault.getAccountSyncState(ACCOUNT).rpcNetworkIdentity).toBe( + getRpcNetworkIdentity(defaultRpcConfig) + ); + }); + + it('fresh-replaces a true network switch while preserving terminal history fields', async () => { + const terminal = transaction('confirmed', 'confirmed', [NOTE_ID]); + terminal.expectedChangeNoteIds = ['change']; + const previousTerminal = structuredClone(terminal); + const oldIdentity = getRpcNetworkIdentity({ + ...defaultRpcConfig, + rpcUrl: 'https://old-rpc.example', + }); + mocks.rpcConfig = { rpcUrl: 'https://new-rpc.example' }; + const vault = unlockedVault({ + note: storedNote(), + transactions: [terminal], + rpcNetworkIdentity: oldIdentity, + }); + mocks.queryV1Balance.mockResolvedValue(balance(200, 'new-first', 'new-last')); + + await vault.syncAccountUTXOs(ACCOUNT, { skipHistory: true }); + + expect(terminal).toEqual(previousTerminal); + expect(vault.getAccountNotes(ACCOUNT)).toHaveLength(1); + expect(vault.getAccountNotes(ACCOUNT)[0].noteId).toBe(generateNoteId('new-first', 'new-last')); + expect(vault.getAccountSyncState(ACCOUNT)).toMatchObject({ + excludeTerminalHistoryFromChangeDetection: true, + utxoSyncInProgress: false, + }); + }); + + it('does not reinterpret an ambiguous pending transaction on another network', async () => { + const ambiguous = transaction('ambiguous', 'broadcast_pending', [NOTE_ID]); + ambiguous.txHash = 'signed-id'; + const oldIdentity = getRpcNetworkIdentity({ + ...defaultRpcConfig, + rpcUrl: 'https://old-rpc.example', + }); + mocks.rpcConfig = { rpcUrl: 'https://new-rpc.example' }; + const note = storedNote('in_flight', ambiguous.id); + const vault = unlockedVault({ + note, + transactions: [ambiguous], + rpcNetworkIdentity: oldIdentity, + }); + mocks.queryV1Balance.mockResolvedValue(balance(200, 'new-first', 'new-last')); + + await expect(vault.syncAccountUTXOs(ACCOUNT, { skipHistory: true })).rejects.toThrow( + /pending on the previously selected network/i + ); + expect(vault.getAccountNotes(ACCOUNT)).toEqual([note]); + expect(vault.getAccountSyncState(ACCOUNT).rpcNetworkIdentity).toBe(oldIdentity); + }); + + it('keeps the in-memory fence closed when the final coherent save fails', async () => { + const identity = getRpcNetworkIdentity(defaultRpcConfig); + const vault = unlockedVault({ note: storedNote(), rpcNetworkIdentity: identity }); + mocks.queryV1Balance.mockResolvedValue(balance(100)); + mocks.encryptGCM + .mockResolvedValueOnce({ iv: new Uint8Array(), ct: new Uint8Array() }) + .mockRejectedValueOnce(new Error('storage failure')); + + await expect(vault.syncAccountUTXOs(ACCOUNT, { skipHistory: true })).rejects.toThrow( + /storage failure/i + ); + expect(vault.getAccountSyncState(ACCOUNT).utxoSyncInProgress).toBe(true); + await expect(vault.accountNeedsSyncForCurrentNetwork(ACCOUNT)).resolves.toBe(true); + }); +}); diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index 2dd576e7..bf421a3c 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -26,6 +26,7 @@ import { import { SubAccount, SeedAccount } from './types'; import { buildMultiNotePayment, + buildUnsignedMultiNotePayment, discoverSpendConditionForNote, type Note, } from './transaction-builder'; @@ -39,7 +40,14 @@ import type { SyncStateStore, AccountSyncState, } from './types'; -import { getEffectiveRpcConfig, getEffectiveRpcEndpoint } from './rpc-config'; +import { + assertRpcNetworkIdentity, + defaultRpcConfig, + getEffectiveRpcConfig, + getEffectiveRpcEndpoint, + getRpcNetworkIdentity, + getTransactionContextSnapshot, +} from './rpc-config'; import { base58 } from '@scure/base'; import { initWasmModules } from './wasm-utils'; import { @@ -47,6 +55,12 @@ import { fetchedToStoredNote, noteToStoredNote, generateNoteId, + reserveAvailableNotes, + releaseOwnedNoteReservations, + releaseUnownedOnChainReservations, + recoverInterruptedExactReservations, + stageExactTransactionReservation, + stageAdditionalTransactionReservation, } from './utxo-utils'; import { computeUTXODiff, @@ -56,10 +70,16 @@ import { areTransactionInputsSpent, matchChangeOutputs, } from './utxo-diff'; -import type { StoredNote, WalletTransaction, FetchedUTXO } from './types'; +import type { + BuiltSimpleTransaction, + StoredNote, + TransactionApprovalContext, + WalletTransaction, + FetchedUTXO, +} from './types'; import { assertNativeRawTx } from './sign-raw-tx-compat'; import type { SignMessageResponse } from '@nockbox/iris-sdk'; -import type { Nicks } from '@nockbox/iris-sdk/wasm'; +import type { Digest, Nicks } from '@nockbox/iris-sdk/wasm'; import { guard } from '@nockbox/iris-sdk/wasm'; import { getTxEngineSettingsForHeight } from './rpc-config'; import { getBothFirstNames } from './first-name-derivation'; @@ -75,10 +95,12 @@ import { buildBridgeTransaction, validateBridgeTransaction } from '@nockbox/iris import { BRIDGE_CONFIG } from './bridge-config'; import { rewriteInsufficientFeeErrorToDecimalNock } from './currency'; import { + assertMatchingTransactionIntent, buildWithAdvisoryFeeRetry, resolveBuiltInputSelection, resolveBuiltTransactionAmounts, } from './transaction-fee'; +import { SerializedTaskQueue } from './serialized-task-queue'; type SendTransactionV2Options = { /** Advisory fee for note selection only. WASM still calculates the actual fee. */ @@ -87,6 +109,10 @@ type SendTransactionV2Options = { accountAddress?: string; }; +type BuiltSimpleTransactionWithContext = BuiltSimpleTransaction & { + transactionContext: TransactionApprovalContext; +}; + async function txEngineSettings(blockHeight: number): Promise { return await getTxEngineSettingsForHeight(blockHeight); } @@ -267,15 +293,23 @@ interface VaultState { enc: EncryptedVault | null; } -function feeEstimateUserFacingError(error: unknown, kind: 'fee' | 'max'): string { +function feeEstimateUserFacingError(error: unknown, kind: 'fee' | 'max' | 'build'): string { const raw = error instanceof Error ? error.message : String(error); if (/digest|canonical|base58|iris-wasm|Digest guard/i.test(raw)) { return kind === 'max' ? 'Could not estimate the maximum send for that recipient. Use a valid Nockchain address.' - : 'Could not estimate the network fee for that recipient. Use a valid Nockchain address.'; + : kind === 'build' + ? 'Could not build a transaction for that recipient. Use a valid Nockchain address.' + : 'Could not estimate the network fee for that recipient. Use a valid Nockchain address.'; } const readable = rewriteInsufficientFeeErrorToDecimalNock(raw); - return (kind === 'max' ? 'Max send estimation failed: ' : 'Fee estimation failed: ') + readable; + const prefix = + kind === 'max' + ? 'Max send estimation failed: ' + : kind === 'build' + ? 'Transaction build failed: ' + : 'Fee estimation failed: '; + return prefix + readable; } /** @@ -334,6 +368,48 @@ export class Vault { /** Serialize Nockblocks history refreshes per account (avoid overlapping background syncs). */ private nockblocksHistoryRefreshChains = new Map>(); + /** Serialize full-account-blob writes so an older snapshot cannot win a race. */ + private accountDataSaveQueue = new SerializedTaskQueue(); + + /** Invalidates queued writes when lock/reset begins. */ + private accountDataEpoch = 0; + + /** Password and cached-key unlocks share one admission slot. */ + private unlockAttemptActive = false; + + private beginUnlockAttempt(): (() => void) | null { + if (this.unlockAttemptActive || !this.state.locked) return null; + this.unlockAttemptActive = true; + let active = true; + return () => { + if (!active) return; + active = false; + this.unlockAttemptActive = false; + }; + } + + private currentUnlockResult(): + | { + ok: boolean; + address: string; + accounts: SubAccount[]; + currentAccount: SubAccount; + activeSeedSourceId: string | null; + } + | { error: string } { + const currentAccount = this.getCurrentAccount(); + if (this.state.locked || !currentAccount) { + return { error: 'Unlock is already in progress' }; + } + return { + ok: true, + address: currentAccount.address, + accounts: this.getAccounts(), + currentAccount, + activeSeedSourceId: this.getActiveSeedSourceId(), + }; + } + /** Cached balances per account (only stored in memory while unlocked) */ private cachedBalances: Record = {}; @@ -358,6 +434,20 @@ export class Vault { return (account?.index ?? -1) === 0; } + private clearDecryptedStateAfterFailedUnlock(): void { + this.state.locked = true; + this.state.accounts = []; + this.state.currentAccountIndex = 0; + this.mnemonic = null; + this.seedAccounts = []; + this.encryptionKey = null; + this.utxoStore = {}; + this.walletTxStore = {}; + this.accountSyncState = {}; + this.cachedBalances = {}; + this.nockblocksHistoryRefreshChains.clear(); + } + /** * Returns a style (icon + color) not already used by any account across all seeds. * @@ -478,8 +568,14 @@ export class Vault { ); } + private getCurrentAccountUnchecked(): SubAccount | null { + const account = this.state.accounts[this.state.currentAccountIndex]; + if (account && !account.hidden) return account; + return this.state.accounts.find(candidate => !candidate.hidden) || null; + } + private getSigningMnemonicForCurrentAccount(): string | null { - const currentAccount = this.getCurrentAccount(); + const currentAccount = this.getCurrentAccountUnchecked(); const seedAccount = this.getSeedAccountForWallet(currentAccount); if (!seedAccount || seedAccount.type !== 'mnemonic') { return null; @@ -546,6 +642,7 @@ export class Vault { password: string, mnemonic?: string ): Promise<{ ok: boolean; address: string; mnemonic: string } | { error: string }> { + const setupLifecycleEpoch = this.accountDataEpoch; // Generate or validate mnemonic const words = mnemonic ? mnemonic.trim() : generateMnemonic(); if (mnemonic && !validateMnemonic(words)) { @@ -604,24 +701,37 @@ export class Vault { }, }; - // Only store encrypted vault and current account index - // Accounts are inside the encrypted vault, not in plaintext - await chrome.storage.local.set({ - [STORAGE_KEYS.ENCRYPTED_VAULT]: encData, - [STORAGE_KEYS.CURRENT_ACCOUNT_INDEX]: 0, - }); + try { + await this.accountDataSaveQueue.run(async () => { + if (setupLifecycleEpoch !== this.accountDataEpoch) { + throw new Error('Vault setup was cancelled because the wallet lifecycle changed'); + } - // Keep wallet unlocked after setup for smooth onboarding UX - // Auto-lock timer will handle locking after inactivity - this.mnemonic = words; - this.encryptionKey = key; // Cache the key for account operations (rename, create, etc.) - this.seedAccounts = [firstSeedAccount]; - this.state = { - locked: false, - accounts: [...firstSeedAccount.accounts], - currentAccountIndex: 0, - enc: encData, - }; + // Only store encrypted vault and current account index. Setup shares + // the lifecycle persistence queue so a later reset is guaranteed to + // clear this write, even when PBKDF/encryption is still in flight. + await chrome.storage.local.set({ + [STORAGE_KEYS.ENCRYPTED_VAULT]: encData, + [STORAGE_KEYS.CURRENT_ACCOUNT_INDEX]: 0, + }); + if (setupLifecycleEpoch !== this.accountDataEpoch) { + throw new Error('Vault setup was cancelled because the wallet lifecycle changed'); + } + + // Keep wallet unlocked after setup for smooth onboarding UX. + this.mnemonic = words; + this.encryptionKey = key; + this.seedAccounts = [firstSeedAccount]; + this.state = { + locked: false, + accounts: [...firstSeedAccount.accounts], + currentAccountIndex: 0, + enc: encData, + }; + }); + } catch { + return { error: ERROR_CODES.LOCKED }; + } return { ok: true, address: firstSeedAccount.accounts[0].address, mnemonic: words }; } @@ -639,36 +749,234 @@ export class Vault { } | { error: string } > { - const stored = await chrome.storage.local.get([ - STORAGE_KEYS.ENCRYPTED_VAULT, - STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA, - STORAGE_KEYS.CURRENT_ACCOUNT_INDEX, - STORAGE_KEYS.UTXO_STORE, // For legacy migration check - STORAGE_KEYS.WALLET_TX_STORE, // For legacy migration check - STORAGE_KEYS.CACHED_BALANCES, // For legacy migration check - ]); - // Change to let to allow reassignment if migrating - let enc = stored[STORAGE_KEYS.ENCRYPTED_VAULT] as EncryptedVault | undefined; - const encAccountData = stored[STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA] as - | EncryptedAccountDataBlob - | undefined; - const currentAccountIndex = - (stored[STORAGE_KEYS.CURRENT_ACCOUNT_INDEX] as number | undefined) || 0; - - if (!enc) { - return { error: ERROR_CODES.NO_VAULT }; + const releaseUnlockAttempt = this.beginUnlockAttempt(); + if (!releaseUnlockAttempt) { + return this.currentUnlockResult(); + } + try { + const unlockLifecycleEpoch = this.accountDataEpoch; + let decryptedStateInstalled = false; + // Never load an older blob while a save from the previous unlocked session + // is still completing. + await this.accountDataSaveQueue.drain(); + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.ENCRYPTED_VAULT, + STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA, + STORAGE_KEYS.CURRENT_ACCOUNT_INDEX, + STORAGE_KEYS.UTXO_STORE, // For legacy migration check + STORAGE_KEYS.WALLET_TX_STORE, // For legacy migration check + STORAGE_KEYS.CACHED_BALANCES, // For legacy migration check + ]); + // Change to let to allow reassignment if migrating + let enc = stored[STORAGE_KEYS.ENCRYPTED_VAULT] as EncryptedVault | undefined; + const encAccountData = stored[STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA] as + | EncryptedAccountDataBlob + | undefined; + const currentAccountIndex = + (stored[STORAGE_KEYS.CURRENT_ACCOUNT_INDEX] as number | undefined) || 0; + + if (!enc) { + return { error: ERROR_CODES.NO_VAULT }; + } + + try { + // Re-derive key using stored KDF parameters (critical for forward compatibility) + const { key } = await deriveKeyPBKDF2( + password, + new Uint8Array(enc.kdf.salt), + enc.kdf.iterations, + enc.kdf.hash + ); + + // Decrypt the vault + const pt = await decryptGCM( + key, + new Uint8Array(enc.cipher.iv), + new Uint8Array(enc.cipher.ct) + ).catch(() => null); + + if (!pt) { + return { error: ERROR_CODES.BAD_PASSWORD }; + } + + const decoded = this.decodeVaultPayload(pt); + const decodedAccounts = decoded.seedAccounts.flatMap(seedAccount => seedAccount.accounts); + + // Load account data from separate encrypted blob + let utxoStore: UTXOStore = {}; + let walletTxStore: WalletTxStore = {}; + let cachedBalances: Record = {}; + let accountSyncState: SyncStateStore = {}; + let loadedFromEncrypted = false; + + if (encAccountData) { + const accountDataPt = await decryptGCM( + key, + new Uint8Array(encAccountData.cipher.iv), + new Uint8Array(encAccountData.cipher.ct) + ).catch(() => null); + + if (accountDataPt) { + const accountData = JSON.parse(accountDataPt) as EncryptedAccountData; + utxoStore = accountData.utxoStore || {}; + for (const key in utxoStore) { + // 0, null, undefined + if (utxoStore[key].blockHeight == null) { + console.log('[Vault] Clearing old UTXO store with no blockHeight'); + delete utxoStore[key]; + } + } + walletTxStore = accountData.walletTxStore || {}; + accountSyncState = accountData.accountSyncState || {}; + cachedBalances = accountData.cachedBalances || {}; + loadedFromEncrypted = true; + } + } + + // Migration: fallback from legacy unencrypted stores + if (!loadedFromEncrypted) { + const legacyUtxoStore = stored[STORAGE_KEYS.UTXO_STORE] as UTXOStore | undefined; + const legacyWalletTxStore = stored[STORAGE_KEYS.WALLET_TX_STORE] as + | WalletTxStore + | undefined; + const legacyCachedBalances = stored[STORAGE_KEYS.CACHED_BALANCES] as + | Record + | undefined; + + utxoStore = legacyUtxoStore || {}; + walletTxStore = legacyWalletTxStore || {}; + cachedBalances = legacyCachedBalances || {}; + } + + // A later lock/reset wins over this slow decrypt. Commit all decrypted + // state synchronously only if the lifecycle epoch is still current. + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + this.seedAccounts = decoded.seedAccounts; + this.encryptionKey = key; + this.utxoStore = utxoStore; + this.walletTxStore = walletTxStore; + this.accountSyncState = accountSyncState; + this.cachedBalances = cachedBalances; + + const resolvedIndex = + currentAccountIndex >= 0 && currentAccountIndex < decodedAccounts.length + ? currentAccountIndex + : 0; + this.state = { + // Keep decrypted state unpublished until recovery/migration durability + // work has completed successfully. + locked: true, + accounts: decodedAccounts, + currentAccountIndex: resolvedIndex, + enc, + }; + decryptedStateInstalled = true; + this.mnemonic = this.getSigningMnemonicForCurrentAccount(); + const recoveredInterruptedExactTransactions = this.recoverInterruptedExactTransactions(); + + const currentAccount = this.state.accounts[resolvedIndex] || this.state.accounts[0]; + + // Persist legacy payload migration + legacy store migration + if (decoded.migrated) { + await this.saveAccountsToVault(); + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + } + if (!loadedFromEncrypted) { + const hasData = + Object.keys(utxoStore).length > 0 || + Object.keys(walletTxStore).length > 0 || + Object.keys(cachedBalances).length > 0 || + Object.keys(accountSyncState).length > 0; + if (hasData) { + await this.saveAccountData(); + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + await chrome.storage.local.remove([ + STORAGE_KEYS.UTXO_STORE, + STORAGE_KEYS.WALLET_TX_STORE, + STORAGE_KEYS.CACHED_BALANCES, + ]); + } + } else if (recoveredInterruptedExactTransactions) { + await this.saveAccountData(); + } + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + this.state.locked = false; + return { + ok: true, + address: currentAccount?.address || '', + accounts: this.state.accounts, + currentAccount, + activeSeedSourceId: this.getSeedAccountForWallet(currentAccount)?.id || null, + }; + } catch (err) { + if (decryptedStateInstalled) { + this.clearDecryptedStateAfterFailedUnlock(); + } + return { + error: + decryptedStateInstalled || unlockLifecycleEpoch !== this.accountDataEpoch + ? ERROR_CODES.LOCKED + : ERROR_CODES.BAD_PASSWORD, + }; + } + } finally { + releaseUnlockAttempt(); } + } + /** + * Unlocks the vault using a cached encryption key (used for session restore) + */ + async unlockWithKey(key: CryptoKey): Promise< + | { + ok: boolean; + address: string; + accounts: SubAccount[]; + currentAccount: SubAccount; + activeSeedSourceId: string | null; + } + | { error: string } + > { + const releaseUnlockAttempt = this.beginUnlockAttempt(); + if (!releaseUnlockAttempt) { + return this.currentUnlockResult(); + } try { - // Re-derive key using stored KDF parameters (critical for forward compatibility) - const { key } = await deriveKeyPBKDF2( - password, - new Uint8Array(enc.kdf.salt), - enc.kdf.iterations, - enc.kdf.hash - ); + const unlockLifecycleEpoch = this.accountDataEpoch; + await this.accountDataSaveQueue.drain(); + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.ENCRYPTED_VAULT, + STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA, + STORAGE_KEYS.CURRENT_ACCOUNT_INDEX, + STORAGE_KEYS.UTXO_STORE, + STORAGE_KEYS.WALLET_TX_STORE, + STORAGE_KEYS.CACHED_BALANCES, + ]); + const enc = stored[STORAGE_KEYS.ENCRYPTED_VAULT] as EncryptedVault | undefined; + const encAccountData = stored[STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA] as + | EncryptedAccountDataBlob + | undefined; + const currentAccountIndex = + (stored[STORAGE_KEYS.CURRENT_ACCOUNT_INDEX] as number | undefined) || 0; + + if (!enc) { + return { error: ERROR_CODES.NO_VAULT }; + } - // Decrypt the vault const pt = await decryptGCM( key, new Uint8Array(enc.cipher.iv), @@ -680,10 +988,8 @@ export class Vault { } const decoded = this.decodeVaultPayload(pt); - this.seedAccounts = decoded.seedAccounts; - this.rebuildFlatAccounts(); + const decodedAccounts = decoded.seedAccounts.flatMap(seedAccount => seedAccount.accounts); - // Load account data from separate encrypted blob let utxoStore: UTXOStore = {}; let walletTxStore: WalletTxStore = {}; let cachedBalances: Record = {}; @@ -696,15 +1002,13 @@ export class Vault { new Uint8Array(encAccountData.cipher.iv), new Uint8Array(encAccountData.cipher.ct) ).catch(() => null); - if (accountDataPt) { const accountData = JSON.parse(accountDataPt) as EncryptedAccountData; utxoStore = accountData.utxoStore || {}; - for (const key in utxoStore) { - // 0, null, undefined - if (utxoStore[key].blockHeight == null) { + for (const utxoKey in utxoStore) { + if (utxoStore[utxoKey].blockHeight == null) { console.log('[Vault] Clearing old UTXO store with no blockHeight'); - delete utxoStore[key]; + delete utxoStore[utxoKey]; } } walletTxStore = accountData.walletTxStore || {}; @@ -714,7 +1018,6 @@ export class Vault { } } - // Migration: fallback from legacy unencrypted stores if (!loadedFromEncrypted) { const legacyUtxoStore = stored[STORAGE_KEYS.UTXO_STORE] as UTXOStore | undefined; const legacyWalletTxStore = stored[STORAGE_KEYS.WALLET_TX_STORE] as @@ -729,6 +1032,10 @@ export class Vault { cachedBalances = legacyCachedBalances || {}; } + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + this.seedAccounts = decoded.seedAccounts; this.encryptionKey = key; this.utxoStore = utxoStore; this.walletTxStore = walletTxStore; @@ -736,181 +1043,65 @@ export class Vault { this.cachedBalances = cachedBalances; const resolvedIndex = - currentAccountIndex >= 0 && currentAccountIndex < this.state.accounts.length + currentAccountIndex >= 0 && currentAccountIndex < decodedAccounts.length ? currentAccountIndex : 0; + this.state = { - locked: false, - accounts: this.state.accounts, + locked: true, + accounts: decodedAccounts, currentAccountIndex: resolvedIndex, enc, }; this.mnemonic = this.getSigningMnemonicForCurrentAccount(); + try { + const recoveredInterruptedExactTransactions = this.recoverInterruptedExactTransactions(); - const currentAccount = this.state.accounts[resolvedIndex] || this.state.accounts[0]; - - // Persist legacy payload migration + legacy store migration - if (decoded.migrated) { - await this.saveAccountsToVault(); - } - if (!loadedFromEncrypted) { - const hasData = - Object.keys(utxoStore).length > 0 || - Object.keys(walletTxStore).length > 0 || - Object.keys(cachedBalances).length > 0 || - Object.keys(accountSyncState).length > 0; - if (hasData) { - await this.saveAccountData(); - await chrome.storage.local.remove([ - STORAGE_KEYS.UTXO_STORE, - STORAGE_KEYS.WALLET_TX_STORE, - STORAGE_KEYS.CACHED_BALANCES, - ]); + const currentAccount = this.state.accounts[resolvedIndex] || this.state.accounts[0]; + if (decoded.migrated) { + await this.saveAccountsToVault(); + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } } - } - return { - ok: true, - address: currentAccount?.address || '', - accounts: this.state.accounts, - currentAccount, - activeSeedSourceId: this.getSeedAccountForWallet(currentAccount)?.id || null, - }; - } catch (err) { - return { error: ERROR_CODES.BAD_PASSWORD }; - } - } - - /** - * Unlocks the vault using a cached encryption key (used for session restore) - */ - async unlockWithKey(key: CryptoKey): Promise< - | { - ok: boolean; - address: string; - accounts: SubAccount[]; - currentAccount: SubAccount; - activeSeedSourceId: string | null; - } - | { error: string } - > { - const stored = await chrome.storage.local.get([ - STORAGE_KEYS.ENCRYPTED_VAULT, - STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA, - STORAGE_KEYS.CURRENT_ACCOUNT_INDEX, - STORAGE_KEYS.UTXO_STORE, - STORAGE_KEYS.WALLET_TX_STORE, - STORAGE_KEYS.CACHED_BALANCES, - ]); - const enc = stored[STORAGE_KEYS.ENCRYPTED_VAULT] as EncryptedVault | undefined; - const encAccountData = stored[STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA] as - | EncryptedAccountDataBlob - | undefined; - const currentAccountIndex = - (stored[STORAGE_KEYS.CURRENT_ACCOUNT_INDEX] as number | undefined) || 0; - - if (!enc) { - return { error: ERROR_CODES.NO_VAULT }; - } - - const pt = await decryptGCM( - key, - new Uint8Array(enc.cipher.iv), - new Uint8Array(enc.cipher.ct) - ).catch(() => null); - - if (!pt) { - return { error: ERROR_CODES.BAD_PASSWORD }; - } - - const decoded = this.decodeVaultPayload(pt); - this.seedAccounts = decoded.seedAccounts; - this.rebuildFlatAccounts(); - - let utxoStore: UTXOStore = {}; - let walletTxStore: WalletTxStore = {}; - let cachedBalances: Record = {}; - let accountSyncState: SyncStateStore = {}; - let loadedFromEncrypted = false; - - if (encAccountData) { - const accountDataPt = await decryptGCM( - key, - new Uint8Array(encAccountData.cipher.iv), - new Uint8Array(encAccountData.cipher.ct) - ).catch(() => null); - if (accountDataPt) { - const accountData = JSON.parse(accountDataPt) as EncryptedAccountData; - utxoStore = accountData.utxoStore || {}; - for (const utxoKey in utxoStore) { - if (utxoStore[utxoKey].blockHeight == null) { - console.log('[Vault] Clearing old UTXO store with no blockHeight'); - delete utxoStore[utxoKey]; + if (!loadedFromEncrypted) { + const hasData = + Object.keys(this.utxoStore).length > 0 || + Object.keys(this.walletTxStore).length > 0 || + Object.keys(this.cachedBalances).length > 0 || + Object.keys(this.accountSyncState).length > 0; + if (hasData) { + await this.saveAccountData(); + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + await chrome.storage.local.remove([ + STORAGE_KEYS.UTXO_STORE, + STORAGE_KEYS.WALLET_TX_STORE, + STORAGE_KEYS.CACHED_BALANCES, + ]); } + } else if (recoveredInterruptedExactTransactions) { + await this.saveAccountData(); } - walletTxStore = accountData.walletTxStore || {}; - accountSyncState = accountData.accountSyncState || {}; - cachedBalances = accountData.cachedBalances || {}; - loadedFromEncrypted = true; - } - } - - if (!loadedFromEncrypted) { - const legacyUtxoStore = stored[STORAGE_KEYS.UTXO_STORE] as UTXOStore | undefined; - const legacyWalletTxStore = stored[STORAGE_KEYS.WALLET_TX_STORE] as WalletTxStore | undefined; - const legacyCachedBalances = stored[STORAGE_KEYS.CACHED_BALANCES] as - | Record - | undefined; - - utxoStore = legacyUtxoStore || {}; - walletTxStore = legacyWalletTxStore || {}; - cachedBalances = legacyCachedBalances || {}; - } - - this.encryptionKey = key; - this.utxoStore = utxoStore; - this.walletTxStore = walletTxStore; - this.accountSyncState = accountSyncState; - this.cachedBalances = cachedBalances; - - const resolvedIndex = - currentAccountIndex >= 0 && currentAccountIndex < this.state.accounts.length - ? currentAccountIndex - : 0; - - this.state = { - locked: false, - accounts: this.state.accounts, - currentAccountIndex: resolvedIndex, - enc, - }; - this.mnemonic = this.getSigningMnemonicForCurrentAccount(); - - const currentAccount = this.state.accounts[resolvedIndex] || this.state.accounts[0]; - if (decoded.migrated) { - await this.saveAccountsToVault(); - } - if (!loadedFromEncrypted) { - const hasData = - Object.keys(this.utxoStore).length > 0 || - Object.keys(this.walletTxStore).length > 0 || - Object.keys(this.cachedBalances).length > 0 || - Object.keys(this.accountSyncState).length > 0; - if (hasData) { - await this.saveAccountData(); - await chrome.storage.local.remove([ - STORAGE_KEYS.UTXO_STORE, - STORAGE_KEYS.WALLET_TX_STORE, - STORAGE_KEYS.CACHED_BALANCES, - ]); + if (unlockLifecycleEpoch !== this.accountDataEpoch) { + return { error: ERROR_CODES.LOCKED }; + } + this.state.locked = false; + return { + ok: true, + address: currentAccount?.address || '', + accounts: this.state.accounts, + currentAccount, + activeSeedSourceId: this.getSeedAccountForWallet(currentAccount)?.id || null, + }; + } catch { + this.clearDecryptedStateAfterFailedUnlock(); + return { error: ERROR_CODES.LOCKED }; } + } finally { + releaseUnlockAttempt(); } - return { - ok: true, - address: currentAccount?.address || '', - accounts: this.state.accounts, - currentAccount, - activeSeedSourceId: this.getSeedAccountForWallet(currentAccount)?.id || null, - }; } /** @@ -926,36 +1117,42 @@ export class Vault { * Requires wallet to be unlocked (encryptionKey must be in memory) */ private async saveAccountsToVault(): Promise { - if (!this.state.enc || !this.encryptionKey) { - throw new Error('Cannot save accounts: vault is locked or not initialized'); - } + const epoch = this.accountDataEpoch; + await this.accountDataSaveQueue.run(async () => { + if (epoch !== this.accountDataEpoch || !this.state.enc || !this.encryptionKey) { + throw new Error('Cannot save accounts: vault is locked or lifecycle changed'); + } - // Re-encrypt seed accounts + child accounts together - const vaultPayload: VaultPayloadV2 = { - version: 2, - seedAccounts: this.seedAccounts, - }; - const payloadJson = JSON.stringify(vaultPayload); - const { iv, ct } = await encryptGCM(this.encryptionKey, new TextEncoder().encode(payloadJson)); + // Capture the latest account list only when this ordered task executes. + const vaultPayload: VaultPayloadV2 = { + version: 2, + seedAccounts: this.seedAccounts, + }; + const payloadJson = JSON.stringify(vaultPayload); + const { iv, ct } = await encryptGCM( + this.encryptionKey, + new TextEncoder().encode(payloadJson) + ); + if (epoch !== this.accountDataEpoch || !this.state.enc) { + throw new Error('Cannot save accounts: vault lifecycle changed'); + } - // Update the encrypted vault with new IV and ciphertext - const encData: EncryptedVault = { - version: 1, - kdf: this.state.enc.kdf, // Reuse same KDF parameters (salt, iterations) - cipher: { - alg: 'AES-GCM', - iv: Array.from(iv), - ct: Array.from(ct), - }, - }; + const encData: EncryptedVault = { + version: 1, + kdf: this.state.enc.kdf, + cipher: { + alg: 'AES-GCM', + iv: Array.from(iv), + ct: Array.from(ct), + }, + }; - // Save updated vault to storage - await chrome.storage.local.set({ - [STORAGE_KEYS.ENCRYPTED_VAULT]: encData, + await chrome.storage.local.set({ [STORAGE_KEYS.ENCRYPTED_VAULT]: encData }); + if (epoch !== this.accountDataEpoch) { + throw new Error('Cannot save accounts: vault lifecycle changed'); + } + this.state.enc = encData; }); - - // Update in-memory state - this.state.enc = encData; } /** @@ -963,16 +1160,20 @@ export class Vault { */ async lock(): Promise<{ ok: boolean }> { this.state.locked = true; - // Clear sensitive data from memory for security - this.state.accounts = []; // Clear accounts to enforce "no addresses while locked" - this.mnemonic = null; - this.seedAccounts = []; - this.encryptionKey = null; - this.utxoStore = {}; - this.walletTxStore = {}; - this.accountSyncState = {}; - this.cachedBalances = {}; - this.nockblocksHistoryRefreshChains.clear(); + this.accountDataEpoch += 1; + await this.accountDataSaveQueue.run(async () => { + // Clear sensitive data only after any already-running encrypted write has + // settled. Queued writes from the old epoch fail before taking a snapshot. + this.state.accounts = []; // Clear accounts to enforce "no addresses while locked" + this.mnemonic = null; + this.seedAccounts = []; + this.encryptionKey = null; + this.utxoStore = {}; + this.walletTxStore = {}; + this.accountSyncState = {}; + this.cachedBalances = {}; + this.nockblocksHistoryRefreshChains.clear(); + }); return { ok: true }; } @@ -980,24 +1181,28 @@ export class Vault { * Resets/deletes the wallet completely (clears all data) */ async reset(): Promise<{ ok: boolean }> { - // Clear all storage - await chrome.storage.local.clear(); - - // Reset in-memory state - this.state = { - locked: true, - accounts: [], - currentAccountIndex: 0, - enc: null, - }; - this.mnemonic = null; - this.seedAccounts = []; - this.encryptionKey = null; // Clear encryption key as well - this.utxoStore = {}; - this.walletTxStore = {}; - this.accountSyncState = {}; - this.cachedBalances = {}; - this.nockblocksHistoryRefreshChains.clear(); + this.state.locked = true; + this.accountDataEpoch += 1; + await this.accountDataSaveQueue.run(async () => { + // Ordered after every already-running account-data write so none can + // recreate ENCRYPTED_ACCOUNT_DATA after the clear. + await chrome.storage.local.clear(); + + this.state = { + locked: true, + accounts: [], + currentAccountIndex: 0, + enc: null, + }; + this.mnemonic = null; + this.seedAccounts = []; + this.encryptionKey = null; // Clear encryption key as well + this.utxoStore = {}; + this.walletTxStore = {}; + this.accountSyncState = {}; + this.cachedBalances = {}; + this.nockblocksHistoryRefreshChains.clear(); + }); return { ok: true }; } @@ -1013,13 +1218,10 @@ export class Vault { * Gets the currently selected sub-account from the flattened account list. */ getCurrentAccount(): SubAccount | null { + if (this.state.locked) return null; // currentAccountIndex refers to the flattened `state.accounts` array position, // not the per-seed derivation index on SubAccount.index. - const account = this.state.accounts[this.state.currentAccountIndex]; - if (account && !account.hidden) return account; - // Safety net: if the stored index points at a hidden account (e.g. after a - // hide operation left a stale index), fall back to the first visible account. - return this.state.accounts.find(a => !a.hidden) || null; + return this.getCurrentAccountUnchecked(); } /** @@ -1030,11 +1232,16 @@ export class Vault { return account?.address || ''; } + /** Whether the selected account has local key material available for signing. */ + canCurrentAccountSignLocally(): boolean { + return !this.state.locked && this.getSigningMnemonicForCurrentAccount() !== null; + } + /** * Returns the flattened sub-account list across all seed sources. */ getAccounts(): SubAccount[] { - return this.state.accounts; + return this.state.locked ? [] : this.state.accounts; } /** @@ -1049,6 +1256,7 @@ export class Vault { * Gets top-level seed/external account sources (mnemonic removed) */ getSeedSources(): Array> { + if (this.state.locked) return []; return this.seedAccounts.map(({ mnemonic: _mnemonic, ...seed }) => seed); } @@ -1222,6 +1430,7 @@ export class Vault { * This is intentional - better privacy, addresses not accessible without password */ async getAddressSafe(): Promise { + if (this.state.locked) return ''; // If unlocked, return from memory if (this.state.accounts.length > 0) { const currentAccount = @@ -1284,6 +1493,31 @@ export class Vault { ); } + /** Heal exact sends interrupted before their signed transaction id was persisted. */ + private recoverInterruptedExactTransactions(): boolean { + let changed = false; + const accountAddresses = new Set([ + ...Object.keys(this.utxoStore), + ...Object.keys(this.walletTxStore), + ]); + for (const accountAddress of accountAddresses) { + const accountStore = this.utxoStore[accountAddress]; + const transactions = this.walletTxStore[accountAddress] ?? []; + const recovered = recoverInterruptedExactReservations( + accountStore?.notes ?? [], + transactions, + accountAddress + ); + if (recovered.released > 0 && accountStore) { + accountStore.version += 1; + } + if (recovered.failed > 0) { + changed = true; + } + } + return changed; + } + /** * Get only available (spendable) notes for an account */ @@ -1316,7 +1550,7 @@ export class Vault { * This blob changes frequently - on every transaction and sync. * All data saved atomically to prevent inconsistency. */ - async saveAccountData(): Promise { + private async persistAccountDataSnapshot(): Promise { if (!this.encryptionKey) { throw new Error('Cannot save account data: vault is locked or not initialized'); } @@ -1343,6 +1577,19 @@ export class Vault { await chrome.storage.local.set({ [STORAGE_KEYS.ENCRYPTED_ACCOUNT_DATA]: encData }); } + async saveAccountData(): Promise { + // The payload is captured inside the queued task, not when the caller + // enqueues it. This prevents a slow older encryption/write from overwriting + // a newer mutation to another account in the same encrypted blob. + const epoch = this.accountDataEpoch; + await this.accountDataSaveQueue.run(async () => { + if (epoch !== this.accountDataEpoch) { + throw new Error('Account data save was cancelled because the vault lifecycle changed'); + } + await this.persistAccountDataSnapshot(); + }); + } + // ============================================================================ // UTXO Store Setters (auto-persist to encrypted storage) // ============================================================================ @@ -1395,23 +1642,12 @@ export class Vault { throw new Error(`No UTXO store for account ${accountAddress}`); } - const noteIdSet = new Set(noteIds); - let lockedCount = 0; - - for (const note of this.utxoStore[accountAddress].notes) { - if (noteIdSet.has(note.noteId)) { - if (note.state !== 'available') { - throw new Error(`Cannot lock note ${note.noteId}: state is ${note.state}`); - } - note.state = 'in_flight'; - note.pendingTxId = walletTxId; - lockedCount++; - } - } - - if (lockedCount !== noteIds.length) { - throw new Error(`Failed to lock all notes: expected ${noteIds.length}, found ${lockedCount}`); - } + reserveAvailableNotes( + this.utxoStore[accountAddress].notes, + accountAddress, + noteIds, + walletTxId + ); this.utxoStore[accountAddress].version += 1; @@ -1442,17 +1678,21 @@ export class Vault { * Release in_flight notes back to available (on tx failure) * Automatically persists to encrypted storage */ - async releaseInFlightNotes(accountAddress: string, noteIds: string[]): Promise { + async releaseInFlightNotes( + accountAddress: string, + noteIds: string[], + walletTxId: string + ): Promise { if (!this.utxoStore[accountAddress]) return; - const noteIdSet = new Set(noteIds); + const released = releaseOwnedNoteReservations( + this.utxoStore[accountAddress].notes, + accountAddress, + noteIds, + walletTxId + ); - for (const note of this.utxoStore[accountAddress].notes) { - if (noteIdSet.has(note.noteId) && note.state === 'in_flight') { - note.state = 'available'; - delete note.pendingTxId; - } - } + if (released === 0) return; this.utxoStore[accountAddress].version += 1; @@ -1536,9 +1776,173 @@ export class Vault { } private capWalletTransactions(accountAddress: string): void { - if (this.walletTxStore[accountAddress]?.length > 1000) { - this.walletTxStore[accountAddress] = this.walletTxStore[accountAddress].slice(0, 1000); - } + const transactions = this.walletTxStore[accountAddress]; + if (!transactions || transactions.length <= 1000) return; + + const isNonterminal = (transaction: WalletTransaction) => + transaction.status === 'created' || + transaction.status === 'broadcast_pending' || + transaction.status === 'mempool_seen' || + transaction.status === 'broadcasted_unconfirmed'; + const pendingCount = transactions.filter(isNonterminal).length; + let terminalBudget = Math.max(0, 1000 - pendingCount); + const retained = transactions.filter(transaction => { + if (isNonterminal(transaction)) return true; + if (terminalBudget === 0) return false; + terminalBudget -= 1; + return true; + }); + + // Keep the same array reference so an in-progress staged transaction can + // restore it on persistence failure. + transactions.splice(0, transactions.length, ...retained); + } + + /** Reserve inputs and add history in one encrypted account-data snapshot. */ + private async reserveNotesAndCreateWalletTransaction( + accountAddress: string, + noteIds: readonly string[], + walletTx: WalletTransaction + ): Promise { + const epoch = this.accountDataEpoch; + await this.accountDataSaveQueue.run(async () => { + if (epoch !== this.accountDataEpoch || this.state.locked) { + throw new Error('Transaction reservation was cancelled because the vault locked'); + } + const accountStore = this.utxoStore[accountAddress]; + if (!accountStore) { + throw new Error(`No UTXO store for account ${accountAddress}`); + } + + const hadTransactionStore = Boolean(this.walletTxStore[accountAddress]); + const transactions = (this.walletTxStore[accountAddress] ??= []); + const previousTransactions = [...transactions]; + const previousVersion = accountStore.version; + const staged = stageExactTransactionReservation( + accountStore.notes, + transactions, + accountAddress, + noteIds, + walletTx + ); + accountStore.version += 1; + this.sortWalletTransactions(accountAddress); + this.capWalletTransactions(accountAddress); + const evictedTransactions = previousTransactions.filter( + transaction => !transactions.includes(transaction) + ); + + try { + await this.persistAccountDataSnapshot(); + } catch (error) { + staged.rollback(); + for (const evicted of evictedTransactions) { + if (!transactions.some(transaction => transaction.id === evicted.id)) { + transactions.push(evicted); + } + } + this.sortWalletTransactions(accountAddress); + accountStore.version = previousVersion; + if (!hadTransactionStore && transactions.length === 0) { + delete this.walletTxStore[accountAddress]; + } + throw error; + } + }); + } + + /** Add owner-bound reservations to an existing history record in one snapshot. */ + private async reserveAdditionalTransactionNotes( + accountAddress: string, + noteIds: readonly string[], + walletTxId: string + ): Promise { + if (noteIds.length === 0) return; + const epoch = this.accountDataEpoch; + await this.accountDataSaveQueue.run(async () => { + if (epoch !== this.accountDataEpoch || this.state.locked) { + throw new Error('Transaction reservation was cancelled because the vault locked'); + } + const accountStore = this.utxoStore[accountAddress]; + const transaction = this.walletTxStore[accountAddress]?.find(tx => tx.id === walletTxId); + if (!accountStore || !transaction) { + throw new Error('Transaction reservation history is unavailable'); + } + const previousVersion = accountStore.version; + const staged = stageAdditionalTransactionReservation( + accountStore.notes, + transaction, + accountAddress, + noteIds + ); + accountStore.version += 1; + try { + await this.persistAccountDataSnapshot(); + } catch (error) { + staged.rollback(); + accountStore.version = previousVersion; + throw error; + } + }); + } + + /** Release an unsubmitted exact send and fail its history in one snapshot. */ + private async failUnsubmittedExactTransaction( + accountAddress: string, + noteIds: readonly string[], + walletTxId: string + ): Promise { + const epoch = this.accountDataEpoch; + await this.accountDataSaveQueue.run(async () => { + if (epoch !== this.accountDataEpoch || this.state.locked) { + throw new Error('Transaction cleanup was cancelled because the vault locked'); + } + const accountStore = this.utxoStore[accountAddress]; + const transactions = this.walletTxStore[accountAddress]; + if (!accountStore || !transactions) return; + + const ownedNotes = accountStore.notes.filter( + note => + noteIds.includes(note.noteId) && + note.accountAddress === accountAddress && + note.state === 'in_flight' && + note.pendingTxId === walletTxId + ); + const transaction = transactions.find(candidate => candidate.id === walletTxId); + const previousStatus = transaction?.status; + const previousUpdatedAt = transaction?.updatedAt; + const previousVersion = accountStore.version; + + const released = releaseOwnedNoteReservations( + accountStore.notes, + accountAddress, + noteIds, + walletTxId + ); + if (released > 0) accountStore.version += 1; + if (transaction) { + transaction.status = 'failed'; + transaction.updatedAt = Date.now(); + this.sortWalletTransactions(accountAddress); + } + if (released === 0 && !transaction) return; + + try { + await this.persistAccountDataSnapshot(); + } catch (error) { + for (const note of ownedNotes) { + note.state = 'in_flight'; + note.pendingTxId = walletTxId; + } + accountStore.version = previousVersion; + if (transaction && previousStatus && transaction.status === 'failed') { + transaction.status = previousStatus; + transaction.updatedAt = previousUpdatedAt ?? transaction.updatedAt; + this.sortWalletTransactions(accountAddress); + } + throw error; + } + }); } private findWalletTransactionIndex( @@ -1575,6 +1979,48 @@ export class Vault { }; } + private assertAccountSyncedForNetwork( + accountAddress: string, + currentNetworkIdentity: string + ): void { + const syncState = this.getAccountSyncState(accountAddress); + if (syncState.utxoSyncInProgress) { + throw new Error('Wallet data sync was interrupted; sync again before transacting'); + } + assertRpcNetworkIdentity(syncState.rpcNetworkIdentity, currentNetworkIdentity); + } + + private assertAccountOperationCurrent(accountAddress: string, lifecycleEpoch: number): void { + if ( + this.state.locked || + lifecycleEpoch !== this.accountDataEpoch || + !this.state.accounts.some(account => account.address === accountAddress) + ) { + throw new Error('Wallet lifecycle changed during account sync'); + } + } + + private async persistCompletedAccountSync( + accountAddress: string, + updates: Partial + ): Promise { + this.setAccountSyncState(accountAddress, { ...updates, utxoSyncInProgress: false }); + try { + await this.saveAccountData(); + } catch (error) { + // The durable snapshot remains fenced from the initial in-progress save; + // keep the in-memory copy fenced too after a failed final commit. + this.setAccountSyncState(accountAddress, { utxoSyncInProgress: true }); + throw error; + } + } + + async accountNeedsSyncForCurrentNetwork(accountAddress: string): Promise { + const currentIdentity = getRpcNetworkIdentity(await getEffectiveRpcConfig()); + const state = this.getAccountSyncState(accountAddress); + return state.utxoSyncInProgress === true || state.rpcNetworkIdentity !== currentIdentity; + } + async updateAccountSyncState( accountAddress: string, updates: Partial @@ -1968,7 +2414,10 @@ export class Vault { }; } - private async refreshPendingTransactionStatuses(accountAddress: string): Promise { + private async refreshPendingTransactionStatuses( + accountAddress: string, + assertCurrent: () => void = () => undefined + ): Promise { if (!isNockblocksConfigured()) { return 0; } @@ -1980,6 +2429,7 @@ export class Vault { const client = createNockblocksClient(); const ownFirstNames = await this.getOwnFirstNameSet(accountAddress); + assertCurrent(); let confirmedCount = 0; for (const tx of pendingTxs) { @@ -1996,6 +2446,7 @@ export class Vault { if (shouldCheckMempool) { try { const mempoolTx = await client.getMempoolTransactionByTxid(trackingId); + assertCurrent(); mempoolSeenAt = mempoolTx ? (mempoolTx.heardAtTimestamp || Math.floor(now / 1000)) * 1000 : tx.mempoolSeenAt; @@ -2020,6 +2471,7 @@ export class Vault { try { const confirmedTx = await client.getTransactionByTxid(trackingId); + assertCurrent(); if (!confirmedTx) { await this.updateWalletTransaction(accountAddress, tx.id, { lastConfirmationCheckAt: now, @@ -2085,7 +2537,7 @@ export class Vault { accountAddress: string, ownFirstNames: Set, client: ReturnType, - opts?: { maxPages?: number } + opts?: { maxPages?: number; assertCurrent?: () => void } ): Promise<{ ingested: number; abortedEmptyFirstPage: boolean }> { const limit = 1000; let offset = 0; @@ -2098,6 +2550,7 @@ export class Vault { limit, offset, }); + opts?.assertCurrent?.(); if (historyTransactions.length === 0) { return { ingested, abortedEmptyFirstPage: offset === 0 && !seenNonemptyPage }; } @@ -2126,7 +2579,7 @@ export class Vault { private async syncConfirmedHistory( accountAddress: string, - opts?: { retryAddressIndexOnEmptyPage?: boolean } + opts?: { retryAddressIndexOnEmptyPage?: boolean; assertCurrent?: () => void } ): Promise { if (!isNockblocksConfigured()) { return 0; @@ -2135,7 +2588,9 @@ export class Vault { const client = createNockblocksClient(); const syncState = this.getAccountSyncState(accountAddress); const ownFirstNames = await this.getOwnFirstNameSet(accountAddress); + opts?.assertCurrent?.(); const tip = await client.getTip(); + opts?.assertCurrent?.(); const maxIncrementalHistoryBlocks = 500; const lastHistorySyncedTip = syncState.lastHistorySyncedTip ?? 0; const historyTipGap = Math.max(tip.height - lastHistorySyncedTip, 0); @@ -2145,15 +2600,18 @@ export class Vault { let ingestResult = await this.ingestNockblocksTransactionsByAddress( accountAddress, ownFirstNames, - client + client, + { assertCurrent: opts?.assertCurrent } ); if (ingestResult.abortedEmptyFirstPage && opts?.retryAddressIndexOnEmptyPage) { await new Promise(resolve => setTimeout(resolve, 1500)); + opts?.assertCurrent?.(); ingestResult = await this.ingestNockblocksTransactionsByAddress( accountAddress, ownFirstNames, - client + client, + { assertCurrent: opts?.assertCurrent } ); } @@ -2169,7 +2627,9 @@ export class Vault { lastHistorySyncedTip: tip.height, lastSyncedHeight: Math.max(syncState.lastSyncedHeight, tip.height), }); + opts?.assertCurrent?.(); await this.saveAccountData(); + opts?.assertCurrent?.(); return syncedCount; } @@ -2182,6 +2642,7 @@ export class Vault { for (let i = 0; i < heights.length; i += 25) { const blocks = await client.getBlocksByHeight(heights.slice(i, i + 25)); + opts?.assertCurrent?.(); for (const block of blocks) { for (const transaction of block.transactions) { const walletTx = this.buildWalletTransactionFromChainTransaction( @@ -2210,7 +2671,7 @@ export class Vault { accountAddress, ownFirstNames, client, - { maxPages: 50 } + { maxPages: 50, assertCurrent: opts?.assertCurrent } ); syncedCount += reconcile.ingested; } @@ -2220,7 +2681,9 @@ export class Vault { lastHistorySyncedTip: tip.height, lastSyncedHeight: Math.max(syncState.lastSyncedHeight, tip.height), }); + opts?.assertCurrent?.(); await this.saveAccountData(); + opts?.assertCurrent?.(); return syncedCount; } @@ -2232,7 +2695,7 @@ export class Vault { */ private async refreshNockblocksHistoryAfterUtxoSync( accountAddress: string, - opts?: { retryAddressIndexOnEmptyPage?: boolean } + opts?: { retryAddressIndexOnEmptyPage?: boolean; assertCurrent?: () => void } ): Promise { if (!isNockblocksConfigured()) { return; @@ -2240,9 +2703,15 @@ export class Vault { await this.enqueueNockblocksHistoryRefresh(accountAddress, async () => { try { - await this.refreshPendingTransactionStatuses(accountAddress); + opts?.assertCurrent?.(); + await this.refreshPendingTransactionStatuses(accountAddress, opts?.assertCurrent); + opts?.assertCurrent?.(); await this.syncConfirmedHistory(accountAddress, opts); + opts?.assertCurrent?.(); } catch (error) { + // Lifecycle invalidation must abort the parent sync, not be downgraded + // to an optional indexer warning. + opts?.assertCurrent?.(); console.warn('[Vault] Nockblocks history refresh failed:', error); } }); @@ -2255,7 +2724,10 @@ export class Vault { * @param accountAddress - Account to sync * @returns Summary of what changed */ - async syncAccountUTXOs(accountAddress: string): Promise<{ + async syncAccountUTXOs( + accountAddress: string, + options: { skipHistory?: boolean } = {} + ): Promise<{ newIncoming: number; newChange: number; spent: number; @@ -2265,32 +2737,143 @@ export class Vault { if (this.state.locked) { throw new Error('Vault is locked'); } + const syncLifecycleEpoch = this.accountDataEpoch; + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); - const endpoint = await getEffectiveRpcEndpoint(); - const rpcClient = createBrowserClient(endpoint); + const syncConfig = await getEffectiveRpcConfig(); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + const syncNetworkIdentity = getRpcNetworkIdentity(syncConfig); + const rpcClient = createBrowserClient(syncConfig.rpcUrl); const syncResult = await withAccountLock(accountAddress, async () => { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); // 1. Fetch current UTXOs from chain const balanceResult = await queryV1Balance(accountAddress, rpcClient); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); const blockHeight = balanceResult.blockHeight; const chainNotes = [...balanceResult.simpleNotes, ...balanceResult.coinbaseNotes]; const fetchedUTXOs = chainNotes.map(n => this.noteToFetchedUTXO(n)); + // Never apply data fetched from an endpoint after settings have moved to + // another network. If settings change immediately after this check, the + // old identity remains attached and subsequent builds still fail closed. + const currentNetworkIdentity = getRpcNetworkIdentity(await getEffectiveRpcConfig()); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + if (currentNetworkIdentity !== syncNetworkIdentity) { + throw new Error('RPC network changed during sync; sync again'); + } + + const priorSyncState = this.getAccountSyncState(accountAddress); + const legacyDefaultNetwork = + priorSyncState.rpcNetworkIdentity === undefined && + syncNetworkIdentity === getRpcNetworkIdentity(defaultRpcConfig); + if (priorSyncState.rpcNetworkIdentity !== syncNetworkIdentity && !legacyDefaultNetwork) { + const transactions = this.getWalletTransactions(accountAddress); + const nonterminalTransactions = transactions.filter( + tx => + tx.status === 'created' || + tx.status === 'broadcast_pending' || + tx.status === 'mempool_seen' || + tx.status === 'broadcasted_unconfirmed' + ); + const provablyUnsubmittedIds = new Set( + nonterminalTransactions + .filter( + tx => + tx.status === 'created' && + Boolean(tx.exactIntentId || tx.locallyManagedSubmission) && + !tx.txHash && + !tx.trackingTxId + ) + .map(tx => tx.id) + ); + const hasAmbiguousTransaction = nonterminalTransactions.some( + tx => !provablyUnsubmittedIds.has(tx.id) + ); + const hasAmbiguousReservation = this.getAccountNotes(accountAddress).some( + note => + note.state === 'in_flight' && + (!note.pendingTxId || !provablyUnsubmittedIds.has(note.pendingTxId)) + ); + if (hasAmbiguousTransaction || hasAmbiguousReservation) { + throw new Error( + 'This account has a transaction pending on the previously selected network; switch back before changing networks' + ); + } + + // A legacy or different-network cache cannot be diffed against this + // endpoint: doing so could falsely confirm transactions or reserve old + // notes. Install a fresh snapshot in one encrypted write instead. + const replacementNotes = fetchedUTXOs.map(note => + fetchedToStoredNote(note, accountAddress, 'available') + ); + const previousVersion = this.utxoStore[accountAddress]?.version ?? 0; + this.utxoStore[accountAddress] = { + notes: replacementNotes, + version: previousVersion + 1, + blockHeight, + }; + for (const transaction of transactions) { + // Preserve terminal history byte-for-byte. The sync-state flag below + // keeps those old-network records out of future change matching. + if (provablyUnsubmittedIds.has(transaction.id)) { + transaction.status = 'failed'; + transaction.updatedAt = Date.now(); + } + } + this.cachedBalances[accountAddress] = balanceResult.totalNock; + await this.persistCompletedAccountSync(accountAddress, { + rpcNetworkIdentity: syncNetworkIdentity, + excludeTerminalHistoryFromChangeDetection: true, + lastSyncedHeight: blockHeight, + }); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + return { + newIncoming: 0, + newChange: 0, + spent: 0, + confirmed: 0, + expired: 0, + }; + } + + // If this multi-write reconciliation is interrupted, builds must not use + // the partial snapshot. The final height/provenance commit clears it. + this.setAccountSyncState(accountAddress, { utxoSyncInProgress: true }); + await this.saveAccountData(); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + // 2. Get local state (from in-memory encrypted store) const localNotes = this.getAccountNotes(accountAddress); + const releasedOrphanReservations = releaseUnownedOnChainReservations( + localNotes, + this.getWalletTransactions(accountAddress), + accountAddress, + new Set(fetchedUTXOs.map(note => note.noteId)) + ); + if (releasedOrphanReservations > 0 && this.utxoStore[accountAddress]) { + this.utxoStore[accountAddress].version += 1; + await this.saveAccountData(); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + } const pendingTxs = this.getPendingOutgoingTransactions(accountAddress); - const allOutgoingTxs = this.getAllOutgoingTransactions(accountAddress); + const allOutgoingTxs = priorSyncState.excludeTerminalHistoryFromChangeDetection + ? pendingTxs + : this.getAllOutgoingTransactions(accountAddress); // 3. Compute diff (pass all outgoing txs for change detection) const diff = computeUTXODiff(localNotes, fetchedUTXOs, pendingTxs, allOutgoingTxs); // 4. Process spent notes if (diff.nowSpent.length > 0) { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); const spentNoteIds = diff.nowSpent.map(n => n.noteId); await this.markNotesSpent(accountAddress, spentNoteIds); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); // Check if any pending transactions are now confirmed for (const tx of pendingTxs) { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); if (areTransactionInputsSpent(tx, diff.nowSpent)) { // Find change outputs for this transaction const changeNoteIds = matchChangeOutputs(tx, diff.newUTXOs, diff.isChangeMap); @@ -2300,6 +2883,7 @@ export class Vault { expectedChangeNoteIds: changeNoteIds, confirmationSource: tx.confirmationSource || 'utxo_fallback', }); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); } } } @@ -2328,7 +2912,9 @@ export class Vault { // Save new notes if (newStoredNotes.length > 0) { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); await this.saveNotes(accountAddress, newStoredNotes, blockHeight); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); } // 5b. Check for pending transactions whose inputs are ALREADY spent @@ -2345,6 +2931,7 @@ export class Vault { ); for (const tx of stillPendingTxs) { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); if (!tx.inputNoteIds || tx.inputNoteIds.length === 0) continue; const allInputsSpent = tx.inputNoteIds.every(noteId => spentNoteIds.has(noteId)); @@ -2354,6 +2941,7 @@ export class Vault { status: 'confirmed', confirmationSource: tx.confirmationSource || 'utxo_fallback', }); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); confirmedFromPreviousSpent++; } } @@ -2364,35 +2952,59 @@ export class Vault { const expiredTxs = findExpiredTransactions(allTxs, Vault.TX_EXPIRY_MS); for (const expiredTx of expiredTxs) { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); if (expiredTx.inputNoteIds && expiredTx.inputNoteIds.length > 0) { - await this.releaseInFlightNotes(accountAddress, expiredTx.inputNoteIds); + await this.releaseInFlightNotes(accountAddress, expiredTx.inputNoteIds, expiredTx.id); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); } await this.updateWalletTransaction(accountAddress, expiredTx.id, { status: 'expired', }); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); } // 7. Handle failed transactions const failedTxs = findFailedTransactions(allTxs, currentNotes); for (const failedTx of failedTxs) { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); if (failedTx.inputNoteIds && failedTx.inputNoteIds.length > 0) { - await this.releaseInFlightNotes(accountAddress, failedTx.inputNoteIds); + await this.releaseInFlightNotes(accountAddress, failedTx.inputNoteIds, failedTx.id); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); } await this.updateWalletTransaction(accountAddress, failedTx.id, { status: 'failed', }); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); } // 8. Cleanup old spent notes to prevent storage bloat await this.removeSpentNotes(accountAddress); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); const confirmedFromNewSpent = pendingTxs.filter(tx => areTransactionInputsSpent(tx, diff.nowSpent) ).length; + // Persist provenance and tip even when the chain returned no new notes. + if (!this.utxoStore[accountAddress]) { + this.utxoStore[accountAddress] = { notes: [], version: 0, blockHeight }; + } else if (this.utxoStore[accountAddress].blockHeight !== blockHeight) { + this.utxoStore[accountAddress].blockHeight = blockHeight; + this.utxoStore[accountAddress].version += 1; + } + if (getRpcNetworkIdentity(await getEffectiveRpcConfig()) !== syncNetworkIdentity) { + throw new Error('RPC network changed during sync; sync again'); + } + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + await this.persistCompletedAccountSync(accountAddress, { + rpcNetworkIdentity: syncNetworkIdentity, + lastSyncedHeight: Math.max(priorSyncState.lastSyncedHeight, blockHeight), + }); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + return { newIncoming, newChange, @@ -2402,11 +3014,15 @@ export class Vault { }; }); + if (options.skipHistory) return syncResult; + // Await outside account lock (slow Nockblocks I/O). Must complete before returning // from sync so the MV3 service worker stays alive until history is ingested. + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); const hasLocalNotes = this.getAccountNotes(accountAddress).length > 0; await this.refreshNockblocksHistoryAfterUtxoSync(accountAddress, { retryAddressIndexOnEmptyPage: hasLocalNotes, + assertCurrent: () => this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch), }); return syncResult; } @@ -2459,30 +3075,53 @@ export class Vault { if (this.state.locked) { throw new Error('Vault is locked'); } + const syncLifecycleEpoch = this.accountDataEpoch; + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); - const endpoint = await getEffectiveRpcEndpoint(); - const rpcClient = createBrowserClient(endpoint); + const syncConfig = await getEffectiveRpcConfig(); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + const syncNetworkIdentity = getRpcNetworkIdentity(syncConfig); + const rpcClient = createBrowserClient(syncConfig.rpcUrl); return withAccountLock(accountAddress, async () => { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); // Check if already initialized const existingNotes = this.getAccountNotes(accountAddress); - if (existingNotes.length > 0) { + if ( + existingNotes.length > 0 && + this.getAccountSyncState(accountAddress).rpcNetworkIdentity === syncNetworkIdentity + ) { return; } // Fetch current UTXOs from chain const balanceResult = await queryV1Balance(accountAddress, rpcClient); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); const blockHeight = balanceResult.blockHeight; const chainNotes = [...balanceResult.simpleNotes, ...balanceResult.coinbaseNotes]; + if (getRpcNetworkIdentity(await getEffectiveRpcConfig()) !== syncNetworkIdentity) { + throw new Error('RPC network changed during sync; sync again'); + } + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + this.setAccountSyncState(accountAddress, { utxoSyncInProgress: true }); + await this.saveAccountData(); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); // Convert to stored notes (all available, no incoming tx records on first init) const storedNotes: StoredNote[] = chainNotes.map(note => noteToStoredNote(note, accountAddress, 'available') ); - // Save notes - if (storedNotes.length > 0) { - await this.saveNotes(accountAddress, storedNotes, blockHeight); + this.setAccountSyncState(accountAddress, { + rpcNetworkIdentity: syncNetworkIdentity, + utxoSyncInProgress: false, + }); + try { + await this.replaceAccountNotes(accountAddress, storedNotes, blockHeight); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + } catch (error) { + this.setAccountSyncState(accountAddress, { utxoSyncInProgress: true }); + throw error; } }); } @@ -2497,19 +3136,38 @@ export class Vault { if (this.state.locked) { throw new Error('Vault is locked'); } + const syncLifecycleEpoch = this.accountDataEpoch; + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); - const endpoint = await getEffectiveRpcEndpoint(); - const rpcClient = createBrowserClient(endpoint); + const syncConfig = await getEffectiveRpcConfig(); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + const syncNetworkIdentity = getRpcNetworkIdentity(syncConfig); + const rpcClient = createBrowserClient(syncConfig.rpcUrl); return withAccountLock(accountAddress, async () => { + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); // Fetch current UTXOs from chain (first-name only) const balanceResult = await queryV1Balance(accountAddress, rpcClient); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); const blockHeight = balanceResult.blockHeight; const chainNotes = [...balanceResult.simpleNotes, ...balanceResult.coinbaseNotes]; const fetchedUTXOs = chainNotes.map(n => this.noteToFetchedUTXO(n)); + if (getRpcNetworkIdentity(await getEffectiveRpcConfig()) !== syncNetworkIdentity) { + throw new Error('RPC network changed during sync; sync again'); + } + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + this.setAccountSyncState(accountAddress, { utxoSyncInProgress: true }); + await this.saveAccountData(); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); // Get existing notes to preserve pending state const existingNotes = this.getAccountNotes(accountAddress); + releaseUnownedOnChainReservations( + existingNotes, + this.getWalletTransactions(accountAddress), + accountAddress, + new Set(fetchedUTXOs.map(note => note.noteId)) + ); // Build map of note IDs that are currently in pending transactions const pendingNoteIds = new Map(); @@ -2541,7 +3199,17 @@ export class Vault { // Replace all notes (but keep pending state) // Note: Full replacement, not a merge - clears notes not on chain - await this.replaceAccountNotes(accountAddress, newStoredNotes, blockHeight); + this.setAccountSyncState(accountAddress, { + rpcNetworkIdentity: syncNetworkIdentity, + utxoSyncInProgress: false, + }); + try { + await this.replaceAccountNotes(accountAddress, newStoredNotes, blockHeight); + this.assertAccountOperationCurrent(accountAddress, syncLifecycleEpoch); + } catch (error) { + this.setAccountSyncState(accountAddress, { utxoSyncInProgress: true }); + throw error; + } }); } @@ -2567,9 +3235,14 @@ export class Vault { if (!masterForSeed || masterForSeed.hidden) { return { ok: true, added: 0 }; } + const discoveryLifecycleEpoch = this.accountDataEpoch; + const assertDiscoveryCurrent = () => + this.assertAccountOperationCurrent(masterForSeed.address, discoveryLifecycleEpoch); await initWasmModules(); + assertDiscoveryCurrent(); const endpoint = await getEffectiveRpcEndpoint(); + assertDiscoveryCurrent(); const rpcClient = createBrowserClient(endpoint); const seedOrdinal = this.getSeedOrdinal(seedAccount.id); @@ -2620,6 +3293,7 @@ export class Vault { indexes.slice(i, i + discoveryQueryConcurrency).map(queryDiscoveryIndex) )) ); + assertDiscoveryCurrent(); } for (const result of discoveryResults) { @@ -2645,10 +3319,12 @@ export class Vault { let added = 0; for (let j = 1; j <= lastWithBalance; j++) { + assertDiscoveryCurrent(); if (existingIndices.has(j)) continue; const { iconStyleId, iconColor } = this.pickUnusedStyleGlobally(); const address = discoveredAddressByIndex.get(j) ?? (await deriveAddress(mnemonic, j)); + assertDiscoveryCurrent(); const newAccount: SubAccount = { name: this.getDefaultChildWalletName(seedOrdinal, j), address, @@ -2663,6 +3339,7 @@ export class Vault { } if (added > 0) { + assertDiscoveryCurrent(); seedAccount.accounts.sort((a, b) => a.index - b.index); this.rebuildFlatAccounts(); @@ -2676,7 +3353,9 @@ export class Vault { this.cachedBalances = merged; await this.saveAccountsToVault(); + assertDiscoveryCurrent(); await this.saveAccountData(); + assertDiscoveryCurrent(); } return { ok: true, added }; @@ -2975,6 +3654,7 @@ export class Vault { if (accountAddress && currentAccount.address !== accountAddress) { throw new Error('Signing account changed after approval'); } + const signingLifecycleEpoch = this.accountDataEpoch; const signingMnemonic = this.getSigningMnemonicForCurrentAccount(); if (!signingMnemonic) { throw new Error('Current account is external and cannot sign locally'); @@ -2982,6 +3662,13 @@ export class Vault { // Initialize WASM modules await initWasmModules(); + if ( + this.state.locked || + this.accountDataEpoch !== signingLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Signing account changed after approval'); + } // Derive the account's private key based on derivation method const masterKey = wasm.deriveMasterKeyFromMnemonic(signingMnemonic, ''); @@ -3141,100 +3828,158 @@ export class Vault { } /** - * Estimate transaction fee by building (but not broadcasting) a tx via WASM - * Uses the same path as real sends (buildMultiNotePayment) so it's SW-safe - * - * @param to - Recipient PKH address (base58-encoded) - * @param amount - Amount in nicks - * @returns Estimated fee in nicks, or { error } if estimation fails + * Build a complete unsigned V1 payment snapshot without reserving inputs, + * writing history, or deriving private key material. */ - async estimateTransactionFee( + async buildSimpleTransaction( to: string, - amount: Nicks - ): Promise<{ fee: number } | { error: string }> { + amount: Nicks, + fee?: Nicks + ): Promise { if (this.state.locked) { return { error: ERROR_CODES.LOCKED }; } - const currentAccount = this.getCurrentAccount(); - if (!currentAccount) { + const capturedAccount = this.getCurrentAccount(); + if (!capturedAccount) { return { error: ERROR_CODES.NO_ACCOUNT }; } - const signingMnemonic = this.getSigningMnemonicForCurrentAccount(); - if (!signingMnemonic) { - return { error: 'Current account is external and cannot sign locally' }; - } - + const capturedLifecycleEpoch = this.accountDataEpoch; try { - // Initialize WASM modules (same as sign/send) - await initWasmModules(); - - // Derive keys - const masterKey = wasm.deriveMasterKeyFromMnemonic(signingMnemonic, ''); - const childIndex = currentAccount.index ?? this.state.currentAccountIndex; - const accountKey = this.isMasterAccount(currentAccount) - ? masterKey - : masterKey.deriveChild(childIndex); - - if (!accountKey.privateKey || !accountKey.publicKey) { - if (!this.isMasterAccount(currentAccount)) { - accountKey.free(); + return await withAccountLock(capturedAccount.address, async () => { + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== capturedAccount.address + ) { + throw new Error('Selected account changed while transaction was being built'); } - masterKey.free(); - return { error: 'Cannot estimate fee: keys unavailable' }; - } - const privateKey = wasm.PrivateKey.fromBytes(accountKey.privateKey); + await initWasmModules(); - try { - const endpoint = await getEffectiveRpcEndpoint(); - const rpcClient = createBrowserClient(endpoint); - const balanceResult = await queryV1Balance(currentAccount.address, rpcClient); + const blockHeight = this.getAccountBlockHeight(capturedAccount.address); + const transactionContext = await getTransactionContextSnapshot(blockHeight); + this.assertAccountSyncedForNetwork( + capturedAccount.address, + transactionContext.networkIdentity + ); - if (balanceResult.utxoCount === 0) { - return { error: 'No UTXOs available. Your wallet may have zero balance.' }; + const availableStoredNotes = this.getAvailableNotes(capturedAccount.address); + if (availableStoredNotes.length === 0) { + throw new Error('No available UTXOs.'); } - const notes = [...balanceResult.simpleNotes, ...balanceResult.coinbaseNotes]; - const blockHeight = balanceResult.blockHeight; + // Supply all available notes to WASM and reconcile the exact subset it + // commits in the resulting transaction. The wallet, never the dApp, + // chooses inputs and always sends change back to this account. + const candidates = [...availableStoredNotes].sort((a, b) => b.assets - a.assets); + const constructedTx = await buildUnsignedMultiNotePayment( + candidates.map(convertStoredNoteForTxBuilder), + to, + amount, + capturedAccount.address, + fee, + capturedAccount.address, + blockHeight, + transactionContext + ); - // Sort UTXOs largest to smallest (WASM will select which ones to use) - const sortedNotes = [...notes].sort((a, b) => b.assets - a.assets); + if ( + constructedTx.nockchainTx.version !== 1 || + !guard.isNockchainTx(constructedTx.nockchainTx) + ) { + throw new Error('Simple transactions must use the V1 transaction format'); + } + const rawTx = wasm.nockchainTxToRawTx(constructedTx.nockchainTx); + if (!guard.isRawTxV1(rawTx)) { + throw new Error('Simple transactions must use the V1 transaction format'); + } - // Convert ALL notes to transaction builder format - // WASM will automatically select the optimal inputs - const txBuilderNotes = await Promise.all( - sortedNotes.map(note => convertNoteForTxBuilder(note, currentAccount.address)) - ); + const builtInputNoteIds = wasm + .rawTxInputNames(rawTx) + .map(name => generateNoteId(String(name.first), String(name.last))); + const builtSelection = resolveBuiltInputSelection(candidates, builtInputNoteIds); + const candidatesById = new Map(candidates.map(note => [note.noteId, note])); + const selectedNativeNotes = builtSelection.inputNoteIds.map(noteId => { + const storedNote = candidatesById.get(noteId); + if (!storedNote?.protoNote) { + throw new Error('Selected input is missing its native note data'); + } + const nativeNote = wasm.noteFromProtobuf(storedNote.protoNote); + if (!guard.isNoteV1(nativeNote)) { + throw new Error('Simple transactions require V1 input notes'); + } + return nativeNote; + }); - // Build a tx with fee = undefined → WASM auto-calculates using DEFAULT_FEE_PER_WORD - // The builder calculates the exact fee needed - const constructedTx = await buildMultiNotePayment( - txBuilderNotes, - to, + const outputs = wasm.rawTxOutputs(rawTx, blockHeight, transactionContext.txEngineSettings); + if (outputs.some(output => !guard.isNoteV1(output))) { + throw new Error('Simple transactions require V1 output notes'); + } + + const actualAmounts = resolveBuiltTransactionAmounts( + builtSelection.selectedTotal, amount, - accountKey.publicKey, - privateKey, - undefined, // let WASM auto-calc - undefined, - blockHeight + constructedTx.feeUsed ); - - // Get the calculated fee from the builder - return { fee: constructedTx.feeUsed }; - } finally { - privateKey.free(); - if (!this.isMasterAccount(currentAccount)) { - accountKey.free(); + const intentId = String(wasm.spendsV1Hash(constructedTx.nockchainTx.spends)) as Digest; + + // Async WASM/config work can outlive an account switch or lock. Fail + // closed before returning note-level wallet data to the caller. + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== capturedAccount.address + ) { + throw new Error('Selected account changed while transaction was being built'); } - masterKey.free(); - } + const currentTransactionContext = await getTransactionContextSnapshot(blockHeight); + if (currentTransactionContext.fingerprint !== transactionContext.fingerprint) { + throw new Error('Transaction network settings changed while the transaction was built'); + } + this.assertAccountSyncedForNetwork( + capturedAccount.address, + currentTransactionContext.networkIdentity + ); + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== capturedAccount.address + ) { + throw new Error('Selected account changed while transaction was being built'); + } + + return { + tx: constructedTx.nockchainTx, + notes: selectedNativeNotes, + outputs, + intentId, + accountAddress: capturedAccount.address as Digest, + blockHeight, + to: to as Digest, + amount, + inputTotal: String(builtSelection.selectedTotal) as Nicks, + fee: String(actualAmounts.fee) as Nicks, + minimumFee: String(constructedTx.minimumFee) as Nicks, + change: String(actualAmounts.expectedChange) as Nicks, + transactionContext, + }; + }); } catch (error) { - console.error('[Vault] Fee estimation failed:', error); - return { error: feeEstimateUserFacingError(error, 'fee') }; + console.error('[Vault] Unsigned transaction build failed:', error); + return { error: feeEstimateUserFacingError(error, 'build') }; } } + /** Legacy wallet-UI projection; the public SDK now owns the build primitive. */ + async estimateTransactionFee( + to: string, + amount: Nicks + ): Promise<{ fee: number } | { error: string }> { + const result = await this.buildSimpleTransaction(to, amount); + return 'error' in result ? result : { fee: Number(result.fee) }; + } + /** * Estimate the maximum amount that can be sent (for "send max" feature) * @@ -3265,7 +4010,6 @@ export class Vault { if (!signingMnemonic) { return { error: 'Current account is external and cannot sign locally' }; } - try { // Initialize WASM modules await initWasmModules(); @@ -3289,8 +4033,13 @@ export class Vault { try { // Get available (not in-flight) notes from in-memory UTXO store - const notes = this.getAvailableNotes(currentAccount.address); const blockHeight = this.getAccountBlockHeight(currentAccount.address); + const transactionContext = await getTransactionContextSnapshot(blockHeight); + this.assertAccountSyncedForNetwork( + currentAccount.address, + transactionContext.networkIdentity + ); + const notes = this.getAvailableNotes(currentAccount.address); if (notes.length === 0) { return { error: 'No spendable UTXOs available.' }; @@ -3461,6 +4210,265 @@ export class Vault { } } + /** + * Reserve, sign, and broadcast the exact unsigned intent that was reviewed. + * No replacement inputs are selected if the snapshot has gone stale. + */ + async sendBuiltSimpleTransaction( + build: BuiltSimpleTransaction, + to: string, + transactionContext: TransactionApprovalContext, + origin: WalletTransaction['origin'] = 'provider_send', + authorizationStillValid: () => boolean = () => true + ): Promise< + { txId: string; walletTx: WalletTransaction; broadcasted: boolean } | { error: string } + > { + if (this.state.locked) { + return { error: ERROR_CODES.LOCKED }; + } + + const capturedAccount = this.getCurrentAccount(); + if (!capturedAccount) { + return { error: ERROR_CODES.NO_ACCOUNT }; + } + if (capturedAccount.address !== build.accountAddress) { + return { error: 'Signing account changed after approval' }; + } + if (to !== build.to) { + return { error: 'Transaction recipient changed after approval' }; + } + if (!this.getSigningMnemonicForCurrentAccount()) { + return { error: 'Current account is external and cannot sign locally' }; + } + const capturedLifecycleEpoch = this.accountDataEpoch; + + return withAccountLock(capturedAccount.address, async () => { + const walletTxId = crypto.randomUUID(); + let selectedNoteIds: string[] = []; + let notesReserved = false; + let broadcastAttempted = false; + let historyCreated = false; + + try { + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== build.accountAddress + ) { + throw new Error('Signing account changed after approval'); + } + if (!authorizationStillValid()) { + throw new Error('Request authorization changed after approval'); + } + + await initWasmModules(); + if (build.tx.version !== 1 || !guard.isNockchainTx(build.tx)) { + throw new Error('Approved transaction is not a V1 transaction'); + } + + const rawTx = wasm.nockchainTxToRawTx(build.tx); + if (!guard.isRawTxV1(rawTx)) { + throw new Error('Approved transaction is not a V1 transaction'); + } + assertMatchingTransactionIntent(build.intentId, String(wasm.spendsV1Hash(build.tx.spends))); + if (String(wasm.rawTxTotalFees(rawTx)) !== build.fee) { + throw new Error('Transaction fee changed after approval'); + } + + const builtInputNoteIds = wasm + .rawTxInputNames(rawTx) + .map(name => generateNoteId(String(name.first), String(name.last))); + const availableNotes = this.getAvailableNotes(build.accountAddress); + let builtSelection: ReturnType; + try { + builtSelection = resolveBuiltInputSelection(availableNotes, builtInputNoteIds); + } catch { + throw new Error('Transaction inputs changed; rebuild required'); + } + if (String(builtSelection.selectedTotal) !== build.inputTotal) { + throw new Error('Transaction input values changed; rebuild required'); + } + selectedNoteIds = builtSelection.inputNoteIds; + + const amounts = resolveBuiltTransactionAmounts( + builtSelection.selectedTotal, + build.amount, + Number(build.fee) + ); + if (String(amounts.fee) !== build.fee || String(amounts.expectedChange) !== build.change) { + throw new Error('Transaction amounts changed after approval'); + } + + // Reject an already-stale approval before creating any reservation or + // history. Height changes within one activation band remain valid. + const currentHeight = this.getAccountBlockHeight(build.accountAddress); + const preReservationContext = await getTransactionContextSnapshot(currentHeight); + if (preReservationContext.fingerprint !== transactionContext.fingerprint) { + throw new Error('Transaction network settings changed; rebuild required'); + } + this.assertAccountSyncedForNetwork( + build.accountAddress, + transactionContext.networkIdentity + ); + + // Perform the final account/lock check immediately before the atomic + // reservation. reserveAvailableNotes preflights every input first. + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== build.accountAddress + ) { + throw new Error('Signing account changed after approval'); + } + if (!authorizationStillValid()) { + throw new Error('Request authorization changed after approval'); + } + const walletTx: WalletTransaction = { + id: walletTxId, + accountAddress: build.accountAddress, + direction: 'outgoing', + createdAt: Date.now(), + updatedAt: Date.now(), + status: 'created', + origin, + exactIntentId: build.intentId, + inputNoteIds: selectedNoteIds, + recipient: to, + amount: Number(build.amount), + fee: Number(build.fee), + expectedChange: Number(build.change), + }; + await this.reserveNotesAndCreateWalletTransaction( + build.accountAddress, + selectedNoteIds, + walletTx + ); + notesReserved = true; + historyCreated = true; + + // Re-evaluate after the durable reservation snapshot, then sign with + // the settings captured by the reviewed build rather than refetching. + const preSignHeight = this.getAccountBlockHeight(build.accountAddress); + const preSignContext = await getTransactionContextSnapshot(preSignHeight); + if (preSignContext.fingerprint !== transactionContext.fingerprint) { + throw new Error('Transaction network settings changed; rebuild required'); + } + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== build.accountAddress + ) { + throw new Error('Signing account changed after approval'); + } + if (!authorizationStillValid()) { + throw new Error('Request authorization changed after approval'); + } + + const signedTx = await this.signRawTx({ + rawTx, + blockHeight: build.blockHeight, + accountAddress: build.accountAddress, + txEngineSettings: transactionContext.txEngineSettings, + }); + if (signedTx.version !== 1 || !guard.isNockchainTx(signedTx)) { + throw new Error('Signed transaction is not a V1 transaction'); + } + assertMatchingTransactionIntent(build.intentId, String(wasm.spendsV1Hash(signedTx.spends))); + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== build.accountAddress + ) { + throw new Error('Signing account changed after approval'); + } + + const signedRawTx = wasm.nockchainTxToRawTx(signedTx); + if (!guard.isRawTxV1(signedRawTx)) { + throw new Error('Signed transaction is not a V1 transaction'); + } + if (String(wasm.rawTxTotalFees(signedRawTx)) !== build.fee) { + throw new Error('Signed transaction fee does not match the approved transaction'); + } + + const protobufTx = wasm.rawTxToProtobuf(signedRawTx); + const signedTxId = String(signedRawTx.id); + walletTx.txHash = signedTxId; + walletTx.trackingTxId = signedTxId; + walletTx.status = 'broadcast_pending'; + await this.updateWalletTransaction(build.accountAddress, walletTxId, { + txHash: signedTxId, + trackingTxId: signedTxId, + status: 'broadcast_pending', + }); + + // This is the final async check before submission. Construct the client + // from this exact snapshot and do not refetch config after it succeeds. + const rpcClient = createBrowserClient(transactionContext.rpcUrl); + const liveHeight = await rpcClient.getCurrentBlockHeight(); + if (!Number.isSafeInteger(liveHeight) || liveHeight <= 0) { + throw new Error('Could not verify the current network height; rebuild required'); + } + const submissionContext = await getTransactionContextSnapshot(liveHeight); + if ( + submissionContext.fingerprint !== transactionContext.fingerprint || + (submissionContext.nextTxEngineActivationHeight !== undefined && + submissionContext.nextTxEngineActivationHeight <= liveHeight + 1) + ) { + throw new Error('Transaction network settings changed; rebuild required'); + } + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== build.accountAddress + ) { + throw new Error('Signing account changed after approval'); + } + if (!authorizationStillValid()) { + throw new Error('Request authorization changed after approval'); + } + // Once submission begins, a network error is ambiguous: the node may + // have accepted the transaction. Preserve reservations and pending + // history so normal reconciliation can determine the outcome. + broadcastAttempted = true; + await rpcClient.sendTransaction(protobufTx); + + walletTx.status = 'broadcasted_unconfirmed'; + await this.updateWalletTransaction(build.accountAddress, walletTxId, { + txHash: signedTxId, + trackingTxId: signedTxId, + status: 'broadcasted_unconfirmed', + lastMempoolCheckAt: Date.now(), + lastConfirmationCheckAt: 0, + }); + + return { txId: signedTxId, walletTx, broadcasted: true }; + } catch (error) { + if (!broadcastAttempted && notesReserved && selectedNoteIds.length > 0) { + try { + if (historyCreated) { + await this.failUnsubmittedExactTransaction( + build.accountAddress, + selectedNoteIds, + walletTxId + ); + } else { + await this.releaseInFlightNotes(build.accountAddress, selectedNoteIds, walletTxId); + } + } catch (releaseError) { + console.error('[Vault] Failed to release exact transaction inputs:', releaseError); + } + } + + const rawMsg = error instanceof Error ? error.message : String(error); + return { + error: broadcastAttempted + ? `Transaction submission status is unknown: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}` + : `Transaction failed: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}`, + }; + } + }); + } + /** * Build, sign, and broadcast a transaction using UTXO store * This is the new preferred method for sending transactions @@ -3503,12 +4511,15 @@ export class Vault { if (!signingMnemonic) { return { error: 'Current account is external and cannot sign locally' }; } + const capturedLifecycleEpoch = this.accountDataEpoch; // Use account lock to prevent race conditions return withAccountLock(currentAccount.address, async () => { // Generate wallet transaction ID upfront const walletTxId = crypto.randomUUID(); let selectedNoteIds: string[] = []; + let notesReserved = false; + let broadcastAttempted = false; try { // Initialize WASM modules @@ -3536,6 +4547,19 @@ export class Vault { const availableStoredNotes = this.getAvailableNotes(currentAccount.address); const blockHeight = this.getAccountBlockHeight(currentAccount.address); + const transactionContext = await getTransactionContextSnapshot(blockHeight); + this.assertAccountSyncedForNetwork( + currentAccount.address, + transactionContext.networkIdentity + ); + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Signing account changed while the transaction was prepared'); + } + if (availableStoredNotes.length === 0) { return { error: 'No available UTXOs.' }; } @@ -3582,10 +4606,6 @@ export class Vault { } selectedNoteIds = selectedStoredNotes.map(n => n.noteId); - // 4. Mark notes as in_flight BEFORE building transaction - await this.markNotesInFlight(currentAccount.address, selectedNoteIds, walletTxId); - - // 5. Create wallet transaction record (status: created) const walletTx: WalletTransaction = { id: walletTxId, accountAddress: currentAccount.address, @@ -3595,16 +4615,27 @@ export class Vault { priceUsdAtTime, status: 'created', origin, + locallyManagedSubmission: true, inputNoteIds: selectedNoteIds, recipient: to, amount: amountNum, fee: selectionFeeNum, expectedChange: expectedChange > 0 ? expectedChange : 0, }; - await this.addWalletTransaction(walletTx); - - const endpoint = await getEffectiveRpcEndpoint(); - const rpcClient = createBrowserClient(endpoint); + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Signing account changed while the transaction was prepared'); + } + // Reserve inputs and create their owner history atomically. + await this.reserveNotesAndCreateWalletTransaction( + currentAccount.address, + selectedNoteIds, + walletTx + ); + notesReserved = true; // For sendMax: set refundPKH = recipient so all funds go to recipient (sweep) const refundAddress = sendMax ? to : undefined; @@ -3629,7 +4660,11 @@ export class Vault { // even if persistence fails after partially reserving the retry set. selectedNoteIds = retryNoteIds; if (additionalNoteIds.length > 0) { - await this.markNotesInFlight(currentAccount.address, additionalNoteIds, walletTxId); + await this.reserveAdditionalTransactionNotes( + currentAccount.address, + additionalNoteIds, + walletTxId + ); } }, build: async candidates => { @@ -3644,7 +4679,8 @@ export class Vault { privateKey, fee, refundAddress, - blockHeight + blockHeight, + transactionContext ); }, }); @@ -3666,7 +4702,11 @@ export class Vault { noteId => !builtInputSet.has(noteId) ); if (unusedReservedNoteIds.length > 0) { - await this.releaseInFlightNotes(currentAccount.address, unusedReservedNoteIds); + await this.releaseInFlightNotes( + currentAccount.address, + unusedReservedNoteIds, + walletTxId + ); } selectedNoteIds = builtSelection.inputNoteIds; @@ -3677,35 +4717,67 @@ export class Vault { sendMax ); - // 7. Broadcast transaction + const protobufTx = wasm.rawTxToProtobuf(builtRawTx); + const signedTxId = String(builtRawTx.id); + + // Persist the signed identifier before submission so a network error + // remains reconcilable rather than releasing potentially spent inputs. walletTx.inputNoteIds = selectedNoteIds; walletTx.fee = actualAmounts.fee; walletTx.expectedChange = actualAmounts.expectedChange; + walletTx.txHash = signedTxId; + walletTx.trackingTxId = signedTxId; + walletTx.status = 'broadcast_pending'; await this.updateWalletTransaction(currentAccount.address, walletTxId, { status: 'broadcast_pending', inputNoteIds: selectedNoteIds, fee: actualAmounts.fee, expectedChange: actualAmounts.expectedChange, + txHash: signedTxId, + trackingTxId: signedTxId, }); - const protobufTx = nockchainTxToProtobuf(constructedTx.nockchainTx); + + const rpcClient = createBrowserClient(transactionContext.rpcUrl); + const liveHeight = await rpcClient.getCurrentBlockHeight(); + if (!Number.isSafeInteger(liveHeight) || liveHeight <= 0) { + throw new Error('Could not verify the current network height; rebuild required'); + } + const submissionContext = await getTransactionContextSnapshot(liveHeight); + if ( + submissionContext.fingerprint !== transactionContext.fingerprint || + (submissionContext.nextTxEngineActivationHeight !== undefined && + submissionContext.nextTxEngineActivationHeight <= liveHeight + 1) + ) { + throw new Error('Transaction network settings changed; rebuild required'); + } + this.assertAccountSyncedForNetwork( + currentAccount.address, + submissionContext.networkIdentity + ); + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Signing account changed while the transaction was built'); + } + broadcastAttempted = true; await rpcClient.sendTransaction(protobufTx); // 8. Update tx status to broadcasted - walletTx.txHash = constructedTx.txId; - walletTx.trackingTxId = constructedTx.txId; walletTx.status = 'broadcasted_unconfirmed'; await this.updateWalletTransaction(currentAccount.address, walletTxId, { fee: actualAmounts.fee, expectedChange: actualAmounts.expectedChange, - txHash: constructedTx.txId, - trackingTxId: constructedTx.txId, + txHash: signedTxId, + trackingTxId: signedTxId, status: 'broadcasted_unconfirmed', lastMempoolCheckAt: Date.now(), lastConfirmationCheckAt: 0, }); return { - txId: constructedTx.txId, + txId: signedTxId, walletTx, broadcasted: true, }; @@ -3720,14 +4792,13 @@ export class Vault { } catch (error) { console.error('[Vault V2] Transaction failed:', error); - // Release in_flight notes on failure - // Using in-memory method + immediate persist (restore spendability) - if (selectedNoteIds.length > 0) { + if (!broadcastAttempted && notesReserved && selectedNoteIds.length > 0) { try { - await this.releaseInFlightNotes(currentAccount.address, selectedNoteIds); - await this.updateWalletTransaction(currentAccount.address, walletTxId, { - status: 'failed', - }); + await this.failUnsubmittedExactTransaction( + currentAccount.address, + selectedNoteIds, + walletTxId + ); } catch (releaseError) { console.error('[Vault V2] Error releasing notes:', releaseError); } @@ -3735,7 +4806,9 @@ export class Vault { const rawMsg = error instanceof Error ? error.message : String(error); return { - error: `Transaction failed: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}`, + error: broadcastAttempted + ? `Transaction submission status is unknown: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}` + : `Transaction failed: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}`, }; } }); @@ -3760,9 +4833,16 @@ export class Vault { estimatedFeeNum: number; expectedChangeNicks: bigint; txEngineSettings: Awaited>; + blockHeight: number; + transactionContext: Awaited>; }> { + const capturedLifecycleEpoch = this.accountDataEpoch; await initWasmModules(); + const blockHeight = this.getAccountBlockHeight(currentAccount.address); + const transactionContext = await getTransactionContextSnapshot(blockHeight); + this.assertAccountSyncedForNetwork(currentAccount.address, transactionContext.networkIdentity); + const availableStoredNotes = this.getAvailableNotes(currentAccount.address); if (availableStoredNotes.length === 0) { throw new Error('No available UTXOs.'); @@ -3795,10 +4875,14 @@ export class Vault { const spendConditions = await Promise.all( sortedStoredNotes.map(async n => { try { - return await discoverSpendConditionForNote(senderPKH, { - nameFirst: n.nameFirst, - originPage: n.originPage, - }); + return await discoverSpendConditionForNote( + senderPKH, + { + nameFirst: n.nameFirst, + originPage: n.originPage, + }, + transactionContext.coinbaseTimelockBlocks + ); } catch { throw new Error( `Spend condition discovery failed for note ${n.noteId} (${n.nameFirst.slice(0, 16)}...)` @@ -3807,8 +4891,7 @@ export class Vault { }) ); - const blockHeight = this.getAccountBlockHeight(currentAccount.address); - const txEngineSettings = await getTxEngineSettingsForHeight(blockHeight); + const txEngineSettings = transactionContext.txEngineSettings; let bridgeResult: Awaited>; try { @@ -3845,6 +4928,18 @@ export class Vault { if (!validation.valid) { throw new Error(validation.error ?? 'Bridge transaction validation failed'); } + const currentContext = await getTransactionContextSnapshot(blockHeight); + if (currentContext.fingerprint !== transactionContext.fingerprint) { + throw new Error('Transaction network settings changed while the bridge was built'); + } + this.assertAccountSyncedForNetwork(currentAccount.address, currentContext.networkIdentity); + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Selected account changed while the bridge was built'); + } return { bridgeResult, @@ -3857,6 +4952,8 @@ export class Vault { estimatedFeeNum: builtFeeNum, expectedChangeNicks, txEngineSettings, + blockHeight, + transactionContext, }; } @@ -3864,7 +4961,12 @@ export class Vault { buildCtx: Awaited> ): Promise { const rawTx = wasm.nockchainTxToRawTx(buildCtx.bridgeResult.transaction); - const signedTx = await this.signRawTx({ rawTx }); + const signedTx = await this.signRawTx({ + rawTx, + blockHeight: buildCtx.blockHeight, + accountAddress: buildCtx.refundPkh, + txEngineSettings: buildCtx.txEngineSettings, + }); const signedRawTx = wasm.nockchainTxToRawTx(signedTx); if (!guard.isRawTxV1(signedRawTx)) { throw new Error('Bridge transaction must be version 1'); @@ -3910,6 +5012,10 @@ export class Vault { } try { + const maxBridgeContext = await getTransactionContextSnapshot( + this.getAccountBlockHeight(currentAccount.address) + ); + this.assertAccountSyncedForNetwork(currentAccount.address, maxBridgeContext.networkIdentity); const availableStoredNotes = this.getAvailableNotes(currentAccount.address); const totalAvailable = availableStoredNotes.reduce((sum, n) => sum + n.assets, 0); const minBridgeAmount = Number(BRIDGE_CONFIG.minAmountNicks); @@ -3977,14 +5083,33 @@ export class Vault { currentAccount: SubAccount, walletTxId: string, buildCtx: Awaited>, - walletTx: WalletTransaction + walletTx: WalletTransaction, + capturedLifecycleEpoch: number ): Promise<{ txId: string; walletTx: WalletTransaction; broadcasted: boolean }> { - await this.markNotesInFlight(currentAccount.address, buildCtx.selectedNoteIds, walletTxId); - await this.addWalletTransaction(walletTx); + let notesReserved = false; + let broadcastAttempted = false; try { + if ( + this.state.locked || + this.accountDataEpoch !== capturedLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Signing account changed while the bridge was built'); + } + await this.reserveNotesAndCreateWalletTransaction( + currentAccount.address, + buildCtx.selectedNoteIds, + walletTx + ); + notesReserved = true; const rawTx = wasm.nockchainTxToRawTx(buildCtx.bridgeResult.transaction); - const signedTx = await this.signRawTx({ rawTx }); + const signedTx = await this.signRawTx({ + rawTx, + blockHeight: buildCtx.blockHeight, + accountAddress: currentAccount.address, + txEngineSettings: buildCtx.txEngineSettings, + }); const signedRawTx = wasm.nockchainTxToRawTx(signedTx); if (!guard.isRawTxV1(signedRawTx)) { throw new Error('Bridge transaction must be version 1'); @@ -4004,13 +5129,40 @@ export class Vault { if (!validation.valid) { throw new Error(validation.error ?? 'Bridge transaction validation failed'); } - const rpcEndpoint = await getEffectiveRpcEndpoint(); - const rpcClient = createBrowserClient(rpcEndpoint); - await rpcClient.sendTransaction(signedProtobufTx); - walletTx.fee = Number(buildCtx.bridgeResult.fee); walletTx.txHash = signedTxId; walletTx.trackingTxId = signedTxId; + walletTx.status = 'broadcast_pending'; + await this.updateWalletTransaction(currentAccount.address, walletTxId, { + fee: walletTx.fee, + txHash: signedTxId, + trackingTxId: signedTxId, + status: 'broadcast_pending', + }); + + const rpcClient = createBrowserClient(buildCtx.transactionContext.rpcUrl); + const liveHeight = await rpcClient.getCurrentBlockHeight(); + if (!Number.isSafeInteger(liveHeight) || liveHeight <= 0) { + throw new Error('Could not verify the current network height; rebuild required'); + } + const submissionContext = await getTransactionContextSnapshot(liveHeight); + if ( + submissionContext.fingerprint !== buildCtx.transactionContext.fingerprint || + (submissionContext.nextTxEngineActivationHeight !== undefined && + submissionContext.nextTxEngineActivationHeight <= liveHeight + 1) + ) { + throw new Error('Transaction network settings changed; rebuild required'); + } + this.assertAccountSyncedForNetwork(currentAccount.address, submissionContext.networkIdentity); + if (this.state.locked || this.getCurrentAccount()?.address !== currentAccount.address) { + throw new Error('Signing account changed while the bridge was built'); + } + if (capturedLifecycleEpoch !== this.accountDataEpoch) { + throw new Error('Wallet lifecycle changed while the bridge was built'); + } + broadcastAttempted = true; + await rpcClient.sendTransaction(signedProtobufTx); + walletTx.status = 'broadcasted_unconfirmed'; await this.updateWalletTransaction(currentAccount.address, walletTxId, { fee: walletTx.fee, @@ -4025,17 +5177,21 @@ export class Vault { broadcasted: true, }; } catch (error) { - if (buildCtx.selectedNoteIds.length > 0) { + if (!broadcastAttempted && notesReserved && buildCtx.selectedNoteIds.length > 0) { try { - await this.releaseInFlightNotes(currentAccount.address, buildCtx.selectedNoteIds); - await this.updateWalletTransaction(currentAccount.address, walletTxId, { - status: 'failed', - }); + await this.failUnsubmittedExactTransaction( + currentAccount.address, + buildCtx.selectedNoteIds, + walletTxId + ); } catch (releaseError) { console.error('[Vault] Error releasing notes:', releaseError); } } - throw error; + const message = error instanceof Error ? error.message : String(error); + throw new Error( + broadcastAttempted ? `Transaction submission status is unknown: ${message}` : message + ); } } @@ -4104,6 +5260,7 @@ export class Vault { if (!currentAccount) { return { error: ERROR_CODES.NO_ACCOUNT }; } + const capturedLifecycleEpoch = this.accountDataEpoch; return withAccountLock(currentAccount.address, async () => { const walletTxId = crypto.randomUUID(); @@ -4124,6 +5281,7 @@ export class Vault { updatedAt: Date.now(), priceUsdAtTime, status: 'created', + locallyManagedSubmission: true, inputNoteIds: buildCtx.selectedNoteIds, recipient: destinationAddress, amount: Number(amountNicks), @@ -4136,7 +5294,8 @@ export class Vault { currentAccount, walletTxId, buildCtx, - walletTx + walletTx, + capturedLifecycleEpoch ); } catch (error) { console.error('[Vault] Bridge transaction failed:', error); @@ -4158,14 +5317,14 @@ export class Vault { rawTx: wasm.RawTx; blockHeight?: number; accountAddress?: string; + txEngineSettings?: wasm.TxEngineSettings; + transactionContextFingerprint?: string; + authorizationStillValid?: () => boolean; }): Promise { if (this.state.locked) { throw new Error('Wallet is locked'); } - // Initialize WASM modules - await initWasmModules(); - const { rawTx } = params; assertNativeRawTx(rawTx); @@ -4176,12 +5335,24 @@ export class Vault { if (params.accountAddress && currentAccount.address !== params.accountAddress) { throw new Error('Signing account changed after approval'); } + const signingLifecycleEpoch = this.accountDataEpoch; const signingMnemonic = this.getSigningMnemonicForCurrentAccount(); if (!signingMnemonic) { throw new Error('Current account is external and cannot sign locally'); } + // Cold WASM initialization is asynchronous; do not let a later lifecycle + // become the baseline for an approval captured in the old session. + await initWasmModules(); + if ( + this.state.locked || + this.accountDataEpoch !== signingLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Signing account changed after approval'); + } + // Derive the account's private key const masterKey = wasm.deriveMasterKeyFromMnemonic(signingMnemonic, ''); const childIndex = currentAccount.index; @@ -4202,7 +5373,7 @@ export class Vault { try { // Use the same account block height as the approval descriptor. const blockHeight = params.blockHeight ?? this.getAccountBlockHeight(currentAccount.address); - const settings = await txEngineSettings(blockHeight); + const settings = params.txEngineSettings ?? (await txEngineSettings(blockHeight)); if (!guard.isRawTxV1(rawTx)) { throw new Error('Only v1 raw transactions are supported'); } @@ -4210,6 +5381,29 @@ export class Vault { try { await builder.sign(privateKey); + if ( + this.state.locked || + this.accountDataEpoch !== signingLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Signing account changed after approval'); + } + + if (params.transactionContextFingerprint) { + const currentContext = await getTransactionContextSnapshot(blockHeight); + if (currentContext.fingerprint !== params.transactionContextFingerprint) { + throw new Error('Transaction network settings changed after approval'); + } + } + if ( + this.state.locked || + this.accountDataEpoch !== signingLifecycleEpoch || + this.getCurrentAccount()?.address !== currentAccount.address || + (params.authorizationStillValid && !params.authorizationStillValid()) + ) { + throw new Error('Signing account or authorization changed after approval'); + } + // Validate before build (surfaces missing unlocks, fee, balanced spends) builder.validate(); @@ -4270,6 +5464,7 @@ export class Vault { inputsVerified: boolean; inputCount: number; outputs: unknown[]; + transactionContext: Awaited>; }> { if (this.state.locked) { throw new Error('Wallet is locked'); @@ -4286,6 +5481,10 @@ export class Vault { throw new Error('No account selected'); } + const blockHeight = this.getAccountBlockHeight(currentAccount.address); + const transactionContext = await getTransactionContextSnapshot(blockHeight); + this.assertAccountSyncedForNetwork(currentAccount.address, transactionContext.networkIdentity); + const inputNames = wasm.rawTxInputNames(rawTx); if (inputNames.length === 0) { throw new Error('Transaction has no inputs'); @@ -4303,8 +5502,7 @@ export class Vault { const inputs = inputsVerified ? resolvedInputs : []; // Keep output derivation on the exact transaction-engine settings used by signRawTx. - const blockHeight = this.getAccountBlockHeight(currentAccount.address); - const settings = await txEngineSettings(blockHeight); + const settings = transactionContext.txEngineSettings; const outputs = wasm .rawTxOutputs(rawTx, blockHeight, settings) .map(output => wasm.noteToProtobuf(output)); @@ -4318,6 +5516,16 @@ export class Vault { intentBuilder.free(); } + const currentContext = await getTransactionContextSnapshot(blockHeight); + if ( + currentContext.fingerprint !== transactionContext.fingerprint || + this.state.locked || + this.getCurrentAccount()?.address !== currentAccount.address + ) { + throw new Error('Transaction review context changed while it was being prepared'); + } + this.assertAccountSyncedForNetwork(currentAccount.address, currentContext.networkIdentity); + return { transactionId: String(wasm.rawTxId(rawTx)), signingIntentId, @@ -4328,6 +5536,7 @@ export class Vault { inputsVerified, inputCount: inputNames.length, outputs, + transactionContext, }; } } diff --git a/tests/transaction-intent-wasm.test.mjs b/tests/transaction-intent-wasm.test.mjs new file mode 100644 index 00000000..49fc8f24 --- /dev/null +++ b/tests/transaction-intent-wasm.test.mjs @@ -0,0 +1,96 @@ +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import initWasm, * as wasm from '@nockbox/iris-wasm'; + +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +beforeAll(async () => { + const require = createRequire(import.meta.url); + const packageEntry = require.resolve('@nockbox/iris-wasm'); + const wasmBytes = await readFile(join(dirname(packageEntry), 'iris_wasm_bg.wasm')); + + // Load the exact binary shipped by the installed package. This avoids the mocked + // WASM facade used by unit tests and exercises the real serialization boundary. + await initWasm({ module_or_path: wasmBytes }); +}); + +describe('native V1 transaction intent across the WASM signing boundary', () => { + it('preserves intent and inputs while producing a signed protobuf transaction', async () => { + const masterKey = wasm.deriveMasterKeyFromMnemonic(MNEMONIC, ''); + const privateKeyBytes = masterKey.privateKey; + if (!privateKeyBytes) { + masterKey.free(); + throw new Error('deterministic mnemonic did not derive a private key'); + } + + const privateKey = wasm.PrivateKey.fromBytes(new Uint8Array(privateKeyBytes)); + const unsignedBuilder = new wasm.TxBuilder(wasm.txEngineSettingsV1Default()); + let signingBuilder; + + try { + const ownerPkh = wasm.hashPublicKey(masterKey.publicKey); + const spendCondition = wasm.spendConditionNewPkh(wasm.pkhSingle(ownerPkh)); + const note = { + version: 1, + origin_page: 42, + name: { + first: wasm.spendConditionFirstName(spendCondition), + last: wasm.hashU64(7n), + _sig: 0, + }, + note_data: [], + assets: '1000000000', + }; + + unsignedBuilder.simpleSpend( + [note], + [{ lock: spendCondition, lock_sp_index: 0 }], + wasm.hashU64(101n), + '1000000', + null, + ownerPkh, + false + ); + + const unsignedTx = unsignedBuilder.build(); + const unsignedRaw = wasm.nockchainTxToRawTx(unsignedTx); + const unsignedIntentHash = wasm.spendsV1Hash(unsignedTx.spends); + const unsignedFee = wasm.rawTxTotalFees(unsignedRaw); + const unsignedInputs = wasm.rawTxInputNames(unsignedRaw); + const clonedBuild = structuredClone({ tx: unsignedTx, notes: [note] }); + const jsonBuild = JSON.parse(JSON.stringify({ tx: unsignedTx, notes: [note] })); + + expect(unsignedInputs).toEqual([note.name]); + expect(wasm.nockchainTxToRawTx(clonedBuild.tx).id).toBe(unsignedRaw.id); + expect(clonedBuild.notes[0]).toEqual(note); + expect(wasm.nockchainTxToRawTx(jsonBuild.tx).id).toBe(unsignedRaw.id); + expect(jsonBuild.notes[0]).toEqual(note); + + signingBuilder = wasm.TxBuilder.fromRawTx(unsignedRaw, wasm.txEngineSettingsV1Default()); + await signingBuilder.sign(privateKey); + signingBuilder.validate(); + + const signedTx = signingBuilder.build(); + const signedRaw = wasm.nockchainTxToRawTx(signedTx); + const signedProtobuf = wasm.rawTxToProtobuf(signedRaw); + + expect(wasm.spendsV1Hash(signedTx.spends)).toBe(unsignedIntentHash); + expect(wasm.rawTxTotalFees(signedRaw)).toBe(unsignedFee); + expect(wasm.rawTxInputNames(signedRaw)).toEqual(unsignedInputs); + expect(wasm.rawTxInputNames(signedRaw)).toHaveLength(1); + + expect(signedRaw.id).toBe(signedTx.id); + expect(signedProtobuf.id).toBe(signedRaw.id); + expect(signedRaw.id).not.toBe(unsignedRaw.id); + expect(signedProtobuf.spends).toHaveLength(signedRaw.spends.length); + } finally { + signingBuilder?.free(); + unsignedBuilder.free(); + privateKey.free(); + masterKey.free(); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 50c072dd..064ce0b7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['extension/**/*.test.ts'], + include: ['extension/**/*.test.ts', 'tests/**/*.test.mjs'], }, }); From 4ec94bf88e42d0d6a38afee654835f9719aaa0a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aurimas=20Bla=C5=BEulionis?= <0x60@pm.me> Date: Wed, 7 Oct 2026 14:35:23 +0100 Subject: [PATCH 21/21] Fix side panels and iris connect --- extension/background/index.ts | 3 - extension/popup/Popup.tsx | 1 + extension/popup/hooks/useApprovalDetection.ts | 102 ++++++++++-------- package-lock.json | 16 +-- package.json | 2 +- 5 files changed, 70 insertions(+), 54 deletions(-) diff --git a/extension/background/index.ts b/extension/background/index.ts index 6b3ab5fa..9b97ba4b 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -431,9 +431,6 @@ async function isPendingRequesterActive(request: PendingRequest): Promise void; @@ -33,6 +34,13 @@ interface UseApprovalDetectionProps { navigate: (screen: Screen) => void; } +const APPROVAL_SCREENS = new Set([ + 'connect-approval', + 'approve-transaction', + 'sign-message', + 'approve-sign-raw-tx', +]); + function getApprovalScreen(type: ApprovalType): Screen { switch (type) { case 'connect': @@ -47,6 +55,7 @@ function getApprovalScreen(type: ApprovalType): Screen { } export function useApprovalDetection({ + currentScreen, walletAddress, walletLocked, setPendingConnectRequest, @@ -56,12 +65,35 @@ export function useApprovalDetection({ navigate, }: UseApprovalDetectionProps) { const walletReady = walletAddress !== null; - const pendingSidePanelApproval = useRef<{ requestId: string; type: ApprovalType } | null>(null); const walletReadyRef = useRef(walletReady); walletReadyRef.current = walletReady; + const currentScreenRef = useRef(currentScreen); + currentScreenRef.current = currentScreen; + const approvalVersion = useRef(0); + + const clearPendingApproval = useCallback(() => { + setPendingConnectRequest(null); + setPendingTransactionRequest(null); + setPendingSignRequest(null); + setPendingSignRawTxRequest(null); + + // An empty approval queue should dismiss stale confirmations, without + // interrupting settings, drafts, or a wallet waiting to be unlocked. + if (APPROVAL_SCREENS.has(currentScreenRef.current)) { + navigate(walletLocked ? 'locked' : 'home'); + } + }, [ + walletLocked, + setPendingConnectRequest, + setPendingTransactionRequest, + setPendingSignRequest, + setPendingSignRawTxRequest, + navigate, + ]); const handleApproval = useCallback( async (requestId: string, type: ApprovalType) => { + const version = ++approvalVersion.current; const targetScreen = getApprovalScreen(type); const lockedScreen: Screen = 'locked'; @@ -69,7 +101,7 @@ export function useApprovalDetection({ const request = await send(INTERNAL_METHODS.GET_PENDING_CONNECTION, [ requestId, ]); - if (request && !('error' in request)) { + if (version === approvalVersion.current && request && !('error' in request)) { setPendingConnectRequest(request); navigate(walletLocked ? lockedScreen : targetScreen); } @@ -80,7 +112,7 @@ export function useApprovalDetection({ const request = await send(INTERNAL_METHODS.GET_PENDING_TRANSACTION, [ requestId, ]); - if (request && !('error' in request)) { + if (version === approvalVersion.current && request && !('error' in request)) { setPendingTransactionRequest(request); navigate(walletLocked ? lockedScreen : targetScreen); } @@ -91,7 +123,7 @@ export function useApprovalDetection({ const request = await send(INTERNAL_METHODS.GET_PENDING_SIGN_REQUEST, [ requestId, ]); - if (request && !('error' in request)) { + if (version === approvalVersion.current && request && !('error' in request)) { setPendingSignRequest(request); navigate(walletLocked ? lockedScreen : targetScreen); } @@ -102,7 +134,7 @@ export function useApprovalDetection({ INTERNAL_METHODS.GET_PENDING_SIGN_RAW_TX_REQUEST, [requestId] ); - if (request && !('error' in request)) { + if (version === approvalVersion.current && request && !('error' in request)) { setPendingSignRawTxRequest(request); navigate(walletLocked ? lockedScreen : targetScreen); } @@ -120,6 +152,22 @@ export function useApprovalDetection({ const handleApprovalRef = useRef(handleApproval); handleApprovalRef.current = handleApproval; + const fetchPendingApproval = useCallback(async () => { + const version = ++approvalVersion.current; + const pending = await send<{ requestId: string; approvalType: ApprovalType } | null>( + INTERNAL_METHODS.GET_PENDING_APPROVAL + ); + + // A newer check or approval notification supersedes this response. + if (version !== approvalVersion.current) return; + + if (pending === null) { + clearPendingApproval(); + } else if (pending?.requestId && pending.approvalType) { + await handleApproval(pending.requestId, pending.approvalType); + } + }, [clearPendingApproval, handleApproval]); + // Side panel: listen for approval messages immediately (may arrive before wallet init) useEffect(() => { if (!isSidePanel()) return; @@ -138,11 +186,6 @@ export function useApprovalDetection({ void handleApprovalRef .current(message.requestId, message.approvalType) .catch(console.error); - } else { - pendingSidePanelApproval.current = { - requestId: message.requestId, - type: message.approvalType, - }; } } }; @@ -151,31 +194,14 @@ export function useApprovalDetection({ return () => chrome.runtime.onMessage.removeListener(handleRuntimeMessage); }, []); - // Side panel: process queued or pending approvals once wallet is ready + // Side panel: fetch the current approval once the 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); - }; - - const queued = pendingSidePanelApproval.current; - if (queued) { - pendingSidePanelApproval.current = null; - void handleApproval(queued.requestId, queued.type).catch(console.error); - return; - } - - fetchPendingApproval(); - }, [walletReady, handleApproval]); + // Re-query even if a notification arrived during initialization: that + // request may have been resolved by another panel before we became ready. + void fetchPendingApproval().catch(console.error); + }, [walletReady, fetchPendingApproval]); // Side panel: re-check when panel becomes visible (e.g. reopened or refocused) useEffect(() => { @@ -184,20 +210,12 @@ export function useApprovalDetection({ 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); + void fetchPendingApproval().catch(console.error); }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => document.removeEventListener('visibilitychange', handleVisibilityChange); - }, [walletReady, handleApproval]); + }, [walletReady, fetchPendingApproval]); // Popup windows: route via URL hash useEffect(() => { diff --git a/package-lock.json b/package-lock.json index 2a8248e9..7e5764f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.2.4", "dependencies": { "@fontsource/lora": "^5.2.8", - "@nockbox/iris-sdk": "0.2.0", + "@nockbox/iris-sdk": "0.2.1", "@scure/base": "^2.0.0", "@scure/bip39": "^2.0.1", "react": "^19.2.0", @@ -550,19 +550,19 @@ } }, "node_modules/@nockbox/iris-sdk": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@nockbox/iris-sdk/-/iris-sdk-0.2.0.tgz", - "integrity": "sha512-9DFA1vtx/oHmKopLbs4dpE4SgsJ+jxNcCDjpOqWDYHmURBLlc7lD2PsmLUTXzb+UMv2MzeQXCzMBCBz+pd5D4Q==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@nockbox/iris-sdk/-/iris-sdk-0.2.1.tgz", + "integrity": "sha512-oIrgG9JZT9QCuDokY82xO4GK3Jwn/vXFVIV9sn/oSKesFdGBbvJGL0ENxeMH3xEnG2kSrQf77UhWrgUsCkRryQ==", "license": "MIT", "dependencies": { - "@nockbox/iris-wasm": "0.2.0", + "@nockbox/iris-wasm": "^0.2.2", "@scure/base": "^2.0.0" } }, "node_modules/@nockbox/iris-wasm": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@nockbox/iris-wasm/-/iris-wasm-0.2.0.tgz", - "integrity": "sha512-FUtYQ8nakzjBXuWKhgw8/QvEzzHfpJkLkkzdv0xu4t6Nn0hNDTkO8SC/Av5O8mTHXsFNTRNva1i4Dab6r29lLA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@nockbox/iris-wasm/-/iris-wasm-0.2.2.tgz", + "integrity": "sha512-hDC1u3IjZ+16zWXKvyp+s/oe99bwwLOo2ImNb2bUWUgBjl8UTxN9V5qtLAZVdWXGNGMZ0Zp61JVzYjSRPZXz+Q==", "license": "MIT" }, "node_modules/@rollup/pluginutils": { diff --git a/package.json b/package.json index 2d63245d..cd048a08 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@fontsource/lora": "^5.2.8", - "@nockbox/iris-sdk": "0.2.0", + "@nockbox/iris-sdk": "0.2.1", "@scure/base": "^2.0.0", "@scure/bip39": "^2.0.1", "react": "^19.2.0",