diff --git a/@shared/api/helpers/extensionMessaging.ts b/@shared/api/helpers/extensionMessaging.ts index 3105152d47..9a6a6dec42 100644 --- a/@shared/api/helpers/extensionMessaging.ts +++ b/@shared/api/helpers/extensionMessaging.ts @@ -71,17 +71,19 @@ export const sendMessageToContentScript = (msg: Msg): Promise => { }); }; -export const sendMessageToBackground = async (msg: Msg): Promise => { +export const sendMessageToBackground = async ( + msg: Msg, +): Promise => { let res; if (DEV_SERVER) { // treat this as an external call because we're making the call from the browser, not the popup res = await sendMessageToContentScript(msg); } else { - res = (await browser.runtime.sendMessage(msg)) as Response; + res = await browser.runtime.sendMessage(msg); } - return res as Response; + return res as T; }; export const FreighterApiNodeError = { diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index f8eb838d50..e2180b6a63 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -13,6 +13,10 @@ import { } from "stellar-sdk"; import BigNumber from "bignumber.js"; import { INDEXER_URL, INDEXER_V2_URL } from "@shared/constants/mercury"; +import { + AutoLockTimeoutMinutes, + DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, +} from "@shared/constants/autoLock"; import { AssetListResponse, AssetsListItem, @@ -60,6 +64,7 @@ import { CollectibleContract, DiscoverData, RecentProtocolEntry, + SaveSettingsResponse, } from "./types"; import { AccountBalancesInterface, @@ -1618,47 +1623,51 @@ export const saveSettings = async ({ isMemoValidationEnabled, isHideDustEnabled, isOpenSidebarByDefault, + autoLockTimeoutMinutes, }: { activePublicKey: string; isDataSharingAllowed: boolean; isMemoValidationEnabled: boolean; isHideDustEnabled: boolean; isOpenSidebarByDefault: boolean; -}): Promise => { - let response = { + autoLockTimeoutMinutes: AutoLockTimeoutMinutes; +}): Promise => { + let response: SaveSettingsResponse = { allowList: DEFAULT_ALLOW_LIST, isDataSharingAllowed: false, networkDetails: MAINNET_NETWORK_DETAILS, networksList: DEFAULT_NETWORKS, isMemoValidationEnabled: true, isRpcHealthy: false, - userNotification: { enabled: false, message: "" }, - settingsState: SettingsState.IDLE, isSorobanPublicEnabled: false, isNonSSLEnabled: false, isHideDustEnabled: true, isOpenSidebarByDefault: false, - error: "", - hiddenAssets: {}, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }; try { - response = await sendMessageToBackground({ + const raw = await sendMessageToBackground< + SaveSettingsResponse | { error: string } + >({ activePublicKey, isDataSharingAllowed, isMemoValidationEnabled, isHideDustEnabled, isOpenSidebarByDefault, + autoLockTimeoutMinutes, type: SERVICE_TYPES.SAVE_SETTINGS, }); + + if ("error" in raw && raw.error) { + throw new Error(raw.error); + } + + response = raw as SaveSettingsResponse; } catch (e) { console.error(e); } - if (response.error) { - throw new Error(response.error); - } - return response; }; @@ -1863,7 +1872,11 @@ export const loadSettings = (): Promise< IndexerSettings & ExperimentalFeatures & { assetsLists: AssetsLists } > => - sendMessageToBackground({ + sendMessageToBackground< + Settings & + IndexerSettings & + ExperimentalFeatures & { assetsLists: AssetsLists } + >({ activePublicKey: null, type: SERVICE_TYPES.LOAD_SETTINGS, }); diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 196085933a..8856367d1a 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -11,6 +11,7 @@ import { CollectibleKey, } from "./types"; import { AssetsListItem } from "@shared/constants/soroban/asset-list"; +import { AutoLockTimeoutMinutes } from "@shared/constants/autoLock"; export interface TokenToAdd { domain: string; @@ -282,6 +283,11 @@ export interface SaveSettingsMessage extends BaseMessage { isMemoValidationEnabled: boolean; isDataSharingAllowed: boolean; isOpenSidebarByDefault: boolean; + autoLockTimeoutMinutes: AutoLockTimeoutMinutes; +} + +export interface UserActivityMessage extends BaseMessage { + type: SERVICE_TYPES.USER_ACTIVITY; } export interface SaveExperimentalFeaturesMessage extends BaseMessage { @@ -547,4 +553,5 @@ export type ServiceMessageRequest = | GetHiddenCollectiblesMessage | MarkQueueActiveMessage | OpenSidebarMessage - | RejectSigningRequestMessage; + | RejectSigningRequestMessage + | UserActivityMessage; diff --git a/@shared/api/types/types.ts b/@shared/api/types/types.ts index c2718762fe..73fb2bad88 100644 --- a/@shared/api/types/types.ts +++ b/@shared/api/types/types.ts @@ -13,6 +13,7 @@ import { AssetsLists, AssetsListItem, } from "../../constants/soroban/asset-list"; +import { AutoLockTimeoutMinutes } from "../../constants/autoLock"; export enum ActionStatus { IDLE = "IDLE", @@ -189,6 +190,7 @@ export interface Preferences { networksList: NetworkDetails[]; isHideDustEnabled: boolean; isOpenSidebarByDefault: boolean; + autoLockTimeoutMinutes: AutoLockTimeoutMinutes; error: string; } @@ -220,6 +222,20 @@ export interface IndexerSettings { userNotification: UserNotification; } +export type SaveSettingsResponse = { + allowList: AllowList; + isDataSharingAllowed: boolean; + isMemoValidationEnabled: boolean; + networkDetails: NetworkDetails; + networksList: NetworkDetails[]; + isRpcHealthy: boolean; + isSorobanPublicEnabled: boolean; + isNonSSLEnabled: boolean; + isHideDustEnabled: boolean; + isOpenSidebarByDefault: boolean; + autoLockTimeoutMinutes: AutoLockTimeoutMinutes; +}; + export type Settings = { allowList: AllowList; networkDetails: NetworkDetails; diff --git a/@shared/constants/autoLock.ts b/@shared/constants/autoLock.ts new file mode 100644 index 0000000000..f27e8b529e --- /dev/null +++ b/@shared/constants/autoLock.ts @@ -0,0 +1,55 @@ +/** + * Single source of truth for the idle auto-lock timeout feature. + * + * The browser session is locked after this many minutes of user + * inactivity across all extension surfaces (popup, sidebar, standalone + * signing windows, grant-access windows). Any user interaction inside + * an extension page resets the timer. + */ +export const VALID_AUTO_LOCK_TIMEOUT_MINUTES = [ + 1, 5, 15, 30, 60, 360, 720, 1440, +] as const; + +export type AutoLockTimeoutMinutes = + (typeof VALID_AUTO_LOCK_TIMEOUT_MINUTES)[number]; + +export const DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES: AutoLockTimeoutMinutes = 720; + +export const isValidAutoLockTimeoutMinutes = ( + value: unknown, +): value is AutoLockTimeoutMinutes => + typeof value === "number" && + (VALID_AUTO_LOCK_TIMEOUT_MINUTES as readonly number[]).includes(value); + +export const coerceAutoLockTimeoutMinutes = ( + value: unknown, +): AutoLockTimeoutMinutes => + isValidAutoLockTimeoutMinutes(value) + ? value + : DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES; + +/** + * Build a human-readable label for a timeout preset. The English fallback is + * constructed in JS and passed to `t()` as `defaultValue`, so even when an + * i18n key is missing (common in dev or partial locales) the rendered label is + * grammatically correct (e.g. "1 minute" / "5 minutes"). Locales can override + * by providing the matching keys. + */ +export const formatTimeoutLabel = ( + minutes: AutoLockTimeoutMinutes, + t: (key: string, opts?: Record) => string, +): string => { + if (minutes >= 60) { + const hours = minutes / 60; + const fallback = hours === 1 ? "1 hour" : `${hours} hours`; + return t("autoLockTimeout.hours", { + count: hours, + defaultValue: fallback, + }); + } + const fallback = minutes === 1 ? "1 minute" : `${minutes} minutes`; + return t("autoLockTimeout.minutes", { + count: minutes, + defaultValue: fallback, + }); +}; diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 098e98d20b..507df44dbb 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -70,6 +70,9 @@ export enum SERVICE_TYPES { CLEAR_RECENT_PROTOCOLS = "CLEAR_RECENT_PROTOCOLS", GET_DISCOVER_WELCOME_SEEN = "GET_DISCOVER_WELCOME_SEEN", DISMISS_DISCOVER_WELCOME = "DISMISS_DISCOVER_WELCOME", + USER_ACTIVITY = "USER_ACTIVITY", + SESSION_LOCKED = "SESSION_LOCKED", + SESSION_UNLOCKED = "SESSION_UNLOCKED", } // SIDEBAR_NAVIGATE is a plain string constant (not in an enum) because it is diff --git a/extension/src/background/__tests__/initAlarmListener.test.ts b/extension/src/background/__tests__/initAlarmListener.test.ts new file mode 100644 index 0000000000..fc11d779c6 --- /dev/null +++ b/extension/src/background/__tests__/initAlarmListener.test.ts @@ -0,0 +1,86 @@ +import browser from "webextension-polyfill"; + +import { SERVICE_TYPES } from "@shared/constants/services"; + +let alarmHandler: + | ((alarm: { name: string }) => void | Promise) + | undefined; + +const mockSendMessage = jest.fn().mockResolvedValue(undefined); + +jest.mock("webextension-polyfill", () => ({ + alarms: { + onAlarm: { + addListener: jest.fn((handler) => { + alarmHandler = handler; + }), + }, + }, + runtime: { + sendMessage: (...args: any[]) => mockSendMessage(...args), + }, +})); + +const mockClearSession = jest.fn().mockResolvedValue(undefined); +jest.mock("background/helpers/session", () => { + const actual = jest.requireActual("background/helpers/session"); + return { + ...actual, + clearSession: (...args: any[]) => mockClearSession(...args), + }; +}); + +jest.mock("background/store", () => ({ + buildStore: jest.fn().mockResolvedValue({}), +})); + +jest.mock("background/helpers/dataStorageAccess", () => ({ + dataStorageAccess: jest.fn().mockReturnValue({}), + browserLocalStorage: {}, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { initAlarmListener } = require("background/index"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { SESSION_ALARM_NAME } = require("background/helpers/session"); + +describe("initAlarmListener", () => { + beforeEach(() => { + mockSendMessage.mockClear(); + mockClearSession.mockClear(); + alarmHandler = undefined; + initAlarmListener(); + }); + + it("clears the session and broadcasts SESSION_LOCKED when the auto-lock alarm fires", async () => { + expect(browser.alarms.onAlarm.addListener).toHaveBeenCalled(); + expect(alarmHandler).toBeDefined(); + + await alarmHandler!({ name: SESSION_ALARM_NAME }); + + expect(mockClearSession).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledWith({ + type: SERVICE_TYPES.SESSION_LOCKED, + }); + }); + + it("swallows sendMessage errors when no UI is open to receive the broadcast", async () => { + mockSendMessage.mockRejectedValueOnce( + new Error("Could not establish connection. Receiving end does not exist."), + ); + + await expect( + alarmHandler!({ name: SESSION_ALARM_NAME }), + ).resolves.toBeUndefined(); + + expect(mockClearSession).toHaveBeenCalledTimes(1); + }); + + it("ignores unrelated alarms", async () => { + await alarmHandler!({ name: "some-other-alarm" }); + + expect(mockClearSession).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/background/ducks/session.ts b/extension/src/background/ducks/session.ts index 512293776a..aea5410407 100644 --- a/extension/src/background/ducks/session.ts +++ b/extension/src/background/ducks/session.ts @@ -10,6 +10,7 @@ import { subscribeAccount as internalSubscribeAccount, } from "background/helpers/account"; import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { TEMPORARY_STORE_ID } from "constants/localStorageTypes"; export const logIn = createAsyncThunk< UiData, @@ -58,6 +59,7 @@ const initialState: InitialState = { }, allAccounts: [] as Account[], migratedMnemonicPhrase: "", + isHardwareWalletLocked: false, }; interface UiData { @@ -70,6 +72,12 @@ interface AppData { privateKey?: string; hashKey?: { key: string }; password?: string; + // True once the idle auto-lock alarm fires on a hardware-wallet-active + // session. Hot-wallet (mnemonic) sessions are gated by `hashKey` + // instead, which `timeoutAccountAccess` already clears. HW sessions + // need a dedicated flag because `getIsHardwareWalletActive` is stored + // in `localStore` and is not cleared by the lock path. + isHardwareWalletLocked?: boolean; } export const sessionSlice = createSlice({ @@ -108,6 +116,19 @@ export const sessionSlice = createSlice({ }, password: "", }), + // Idle auto-lock for hardware-wallet sessions. `timeoutAccountAccess` + // clears the hot-wallet `hashKey`, but hardware-wallet "unlocked" + // state is read off `localStore.isHardwareWalletActive` and is + // unaffected by that — so without this flag, the idle alarm firing + // on an HW-only session would be a silent no-op. + lockHardwareWallet: (state) => ({ + ...state, + isHardwareWalletLocked: true, + }), + unlockHardwareWallet: (state) => ({ + ...state, + isHardwareWalletLocked: false, + }), updateAccountName: ( state, action: { payload: { publicKey: string; updatedAccountName: string } }, @@ -155,6 +176,8 @@ export const { logOut, setActiveHashKey, timeoutAccountAccess, + lockHardwareWallet, + unlockHardwareWallet, setMigratedMnemonicPhrase, updateAccountName, }, @@ -178,9 +201,21 @@ export const buildHasPrivateKeySelector = (localStore: DataStorageAccess) => const isHardwareWalletActive = await getIsHardwareWalletActive({ localStore, }); - return isHardwareWalletActive || !!session?.hashKey?.key; + if (isHardwareWalletActive && !session?.isHardwareWalletLocked) { + return true; + } + if (!session?.hashKey?.key) { + return false; + } + const temporaryStore = await localStore.getItem(TEMPORARY_STORE_ID); + return !!temporaryStore && Object.keys(temporaryStore).length > 0; }); +export const isHardwareWalletLockedSelector = createSelector( + sessionSelector, + (session) => !!session?.isHardwareWalletLocked, +); + export const hashKeySelector = createSelector( sessionSelector, (session) => session.hashKey, diff --git a/extension/src/background/helpers/__tests__/session.test.ts b/extension/src/background/helpers/__tests__/session.test.ts index eb2f619194..a8e77ce57c 100644 --- a/extension/src/background/helpers/__tests__/session.test.ts +++ b/extension/src/background/helpers/__tests__/session.test.ts @@ -2,7 +2,21 @@ import { deriveKeyFromString, encryptHashString, decryptHashString, + SessionTimer, + SESSION_ALARM_NAME, } from "../session"; +import browser from "webextension-polyfill"; +import { + AUTO_LOCK_TIMEOUT_MINUTES_ID, + TEMPORARY_STORE_ID, +} from "constants/localStorageTypes"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; +import { buildHasPrivateKeySelector } from "background/ducks/session"; +import { getIsHardwareWalletActive } from "background/helpers/account"; + +jest.mock("background/helpers/account", () => ({ + getIsHardwareWalletActive: jest.fn(), +})); describe("session", () => { it("should be able to encrypt and decrypt a string", async () => { @@ -85,3 +99,127 @@ describe("session", () => { expect(areEqual).toBe(false); }); }); + +describe("SessionTimer", () => { + const createMock = jest.fn().mockResolvedValue(undefined); + const clearMock = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + createMock.mockClear(); + clearMock.mockClear(); + (browser as any).alarms = { create: createMock, clear: clearMock }; + }); + + const makeLocalStore = (stored: unknown) => + ({ + getItem: jest.fn().mockImplementation((key: string) => { + if (key === AUTO_LOCK_TIMEOUT_MINUTES_ID) + return Promise.resolve(stored); + return Promise.resolve(null); + }), + setItem: jest.fn(), + remove: jest.fn(), + }) as any; + + it("resetSession arms the alarm using the stored timeout", async () => { + const timer = new SessionTimer(makeLocalStore(30)); + await timer.resetSession(); + expect(createMock).toHaveBeenCalledWith(SESSION_ALARM_NAME, { + delayInMinutes: 30, + }); + }); + + it("startSession is an alias for resetSession", async () => { + const timer = new SessionTimer(makeLocalStore(5)); + await timer.startSession(); + expect(createMock).toHaveBeenCalledWith(SESSION_ALARM_NAME, { + delayInMinutes: 5, + }); + }); + + it("falls back to the default when no timeout is persisted", async () => { + const timer = new SessionTimer(makeLocalStore(null)); + await timer.resetSession(); + expect(createMock).toHaveBeenCalledWith(SESSION_ALARM_NAME, { + delayInMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, + }); + }); + + it("falls back to the default when the persisted value is invalid", async () => { + const timer = new SessionTimer(makeLocalStore(7)); + await timer.resetSession(); + expect(createMock).toHaveBeenCalledWith(SESSION_ALARM_NAME, { + delayInMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, + }); + }); + + it("re-reads the persisted timeout on every reset", async () => { + const localStore = { + getItem: jest.fn().mockResolvedValueOnce(15).mockResolvedValueOnce(60), + setItem: jest.fn(), + remove: jest.fn(), + } as any; + const timer = new SessionTimer(localStore); + await timer.resetSession(); + await timer.resetSession(); + expect(createMock).toHaveBeenNthCalledWith(1, SESSION_ALARM_NAME, { + delayInMinutes: 15, + }); + expect(createMock).toHaveBeenNthCalledWith(2, SESSION_ALARM_NAME, { + delayInMinutes: 60, + }); + }); + + it("stopSession clears the alarm", async () => { + const timer = new SessionTimer(makeLocalStore(15)); + await timer.stopSession(); + expect(clearMock).toHaveBeenCalledWith(SESSION_ALARM_NAME); + }); +}); + +describe("buildHasPrivateKeySelector", () => { + const mockGetIsHardwareWalletActive = + getIsHardwareWalletActive as jest.MockedFunction< + typeof getIsHardwareWalletActive + >; + + beforeEach(() => { + mockGetIsHardwareWalletActive.mockResolvedValue(false); + }); + + it("returns true when the hash key exists and the temporary store is populated", async () => { + const selector = buildHasPrivateKeySelector({ + getItem: jest.fn().mockImplementation((key: string) => { + if (key === TEMPORARY_STORE_ID) { + return Promise.resolve({ "key-id-0": "encrypted-blob" }); + } + return Promise.resolve(null); + }), + } as any); + + await expect( + selector({ session: { hashKey: { key: "hash-key" } } } as any), + ).resolves.toBe(true); + }); + + it("returns false when the hash key exists but the temporary store is empty", async () => { + const selector = buildHasPrivateKeySelector({ + getItem: jest.fn().mockResolvedValue({}), + } as any); + + await expect( + selector({ session: { hashKey: { key: "hash-key" } } } as any), + ).resolves.toBe(false); + }); + + it("returns true for unlocked hardware-wallet sessions", async () => { + mockGetIsHardwareWalletActive.mockResolvedValue(true); + const selector = buildHasPrivateKeySelector({ + getItem: jest.fn().mockResolvedValue(null), + } as any); + + await expect( + selector({ session: { isHardwareWalletLocked: false } } as any), + ).resolves.toBe(true); + }); +}); diff --git a/extension/src/background/helpers/session.ts b/extension/src/background/helpers/session.ts index 8709b595f3..90ad68bc0e 100644 --- a/extension/src/background/helpers/session.ts +++ b/extension/src/background/helpers/session.ts @@ -6,26 +6,66 @@ import { hashKeySelector, SessionState, timeoutAccountAccess, + lockHardwareWallet, } from "../ducks/session"; +import { flushSessionStore } from "../store"; import { DataStorageAccess } from "./dataStorageAccess"; -import { TEMPORARY_STORE_ID } from "../../constants/localStorageTypes"; +import { + AUTO_LOCK_TIMEOUT_MINUTES_ID, + TEMPORARY_STORE_ID, +} from "../../constants/localStorageTypes"; +import { + AutoLockTimeoutMinutes, + coerceAutoLockTimeoutMinutes, +} from "@shared/constants/autoLock"; import { encode, decode } from "./base64-arraybuffer"; -// 24 hours -const SESSION_LENGTH = 60 * 24; export const SESSION_ALARM_NAME = "session-timer"; +/** + * Idle-based auto-lock timer. + * + * The browser session is locked after a configurable number of minutes + * of user inactivity. Any genuine user interaction in an extension page + * pings the background, which calls `resetSession()` to rearm the alarm + * at `now + configured timeout`. The alarm is implemented as a named + * `browser.alarms` entry, so creating it with the same name replaces + * any in-flight deadline atomically — no separate clear step needed. + */ export class SessionTimer { - duration = 1000 * 60 * SESSION_LENGTH; - runningTimeout: null | ReturnType = null; - constructor(duration?: number) { - this.duration = duration || this.duration; + private readonly localStore: DataStorageAccess; + + constructor(localStore: DataStorageAccess) { + this.localStore = localStore; } - startSession() { - browser?.alarms.create(SESSION_ALARM_NAME, { - delayInMinutes: SESSION_LENGTH, - }); + private async getTimeoutMinutes(): Promise { + const stored = await this.localStore.getItem(AUTO_LOCK_TIMEOUT_MINUTES_ID); + return coerceAutoLockTimeoutMinutes(stored); + } + + /** + * (Re)arm the auto-lock alarm. Reads the persisted timeout on every + * call so that settings changes take effect immediately. + */ + async resetSession() { + const delayInMinutes = await this.getTimeoutMinutes(); + await browser?.alarms.create(SESSION_ALARM_NAME, { delayInMinutes }); + } + + /** + * Alias kept so unlock paths can read naturally + * (`sessionTimer.startSession()`). + */ + async startSession() { + await this.resetSession(); + } + + /** + * Cancel any pending auto-lock alarm. Used by explicit sign-out. + */ + async stopSession() { + await browser?.alarms.clear(SESSION_ALARM_NAME); } } @@ -266,5 +306,11 @@ export const clearSession = async ({ sessionStore, }: ClearSession) => { sessionStore.dispatch(timeoutAccountAccess()); + // Locks hardware-wallet sessions too. `timeoutAccountAccess` clears + // the hot-wallet hashKey, but HW unlocked-state is read from + // `localStore.isHardwareWalletActive` and is unaffected — so without + // this dispatch the idle alarm would be a no-op on HW-only sessions. + sessionStore.dispatch(lockHardwareWallet()); + await flushSessionStore(sessionStore); await localStore.remove(TEMPORARY_STORE_ID); }; diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 8f56703919..0709dc4cfe 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -29,6 +29,7 @@ import { SIDEBAR_DISCONNECT_DEBOUNCE_MS, } from "./helpers/queueCleanup"; import { removeUuidFromAllQueues } from "./messageListener/handlers/rejectSigningRequest"; +import { broadcastSessionState } from "./messageListener/helpers/broadcast-session-state"; import { SESSION_ALARM_NAME, SessionTimer, @@ -53,7 +54,7 @@ import { } from "@stellar/typescript-wallet-sdk-km"; import { BrowserStorageConfigParams } from "@stellar/typescript-wallet-sdk-km/lib/Plugins/BrowserStorageFacade"; -const sessionTimer = new SessionTimer(); +const sessionTimer = new SessionTimer(dataStorageAccess(browserLocalStorage)); export const initContentScriptMessageListener = () => { browser?.runtime?.onMessage?.addListener((message) => { @@ -231,6 +232,7 @@ export const initAlarmListener = () => { if (name === SESSION_ALARM_NAME) { await clearSession({ sessionStore, localStore }); + await broadcastSessionState(SERVICE_TYPES.SESSION_LOCKED); } }); }; diff --git a/extension/src/background/messageListener/__tests__/createAccount.test.ts b/extension/src/background/messageListener/__tests__/createAccount.test.ts index 44a6dd24bd..37adb37024 100644 --- a/extension/src/background/messageListener/__tests__/createAccount.test.ts +++ b/extension/src/background/messageListener/__tests__/createAccount.test.ts @@ -47,7 +47,7 @@ describe("Create account message listener", () => { mockSessionStore, mockDataStorage, mockKeyManager, - testAlarm, + testAlarm as any, { id: "fake-extension-id" }, )) as Awaited>; @@ -74,7 +74,7 @@ describe("Create account message listener", () => { mockSessionStore, mockDataStorage, mockKeyManager, - testAlarm, + testAlarm as any, { id: "fake-extension-id" }, )) as Awaited>; const keyId = await mockDataStorage.getItem(KEY_ID); @@ -88,7 +88,7 @@ describe("Create account message listener", () => { mockSessionStore, mockDataStorage, mockKeyManager, - testAlarm, + testAlarm as any, { id: "fake-extension-id" }, )) as Awaited>; const secondKeyId = await mockDataStorage.getItem(KEY_ID); @@ -111,7 +111,7 @@ describe("Create account message listener", () => { mockSessionStore, mockDataStorage, mockKeyManager, - testAlarm, + testAlarm as any, { id: "fake-extension-id" }, )) as Awaited>; const keyId = await mockDataStorage.getItem(KEY_ID); @@ -125,7 +125,7 @@ describe("Create account message listener", () => { mockSessionStore, mockDataStorage, mockKeyManager, - testAlarm, + testAlarm as any, { id: "fake-extension-id" }, )) as Awaited>; const secondKeyId = await mockDataStorage.getItem(KEY_ID); diff --git a/extension/src/background/messageListener/__tests__/handleSignedHwPayload.test.ts b/extension/src/background/messageListener/__tests__/handleSignedHwPayload.test.ts new file mode 100644 index 0000000000..f83e52fd8a --- /dev/null +++ b/extension/src/background/messageListener/__tests__/handleSignedHwPayload.test.ts @@ -0,0 +1,87 @@ +import { handleSignedHwPayload } from "../handlers/handleSignedHwPayload"; +import { + isHardwareWalletLockedSelector, + lockHardwareWallet, + sessionSlice, + unlockHardwareWallet, +} from "background/ducks/session"; + +jest.mock("@sentry/browser", () => ({ + captureException: jest.fn(), +})); + +const makeSessionTimer = (onReset?: () => void) => + ({ + resetSession: jest.fn().mockImplementation(async () => { + onReset?.(); + }), + startSession: jest.fn().mockResolvedValue(undefined), + stopSession: jest.fn().mockResolvedValue(undefined), + }) as any; + +describe("handleSignedHwPayload", () => { + it("resets the session before resolving the queued hardware-wallet payload", async () => { + const callOrder: string[] = []; + const response = jest.fn(() => callOrder.push("response")); + const sessionTimer = makeSessionTimer(() => callOrder.push("reset")); + const responseQueue = [{ uuid: "uuid-1", response }]; + + const result = await handleSignedHwPayload({ + request: { + uuid: "uuid-1", + signedPayload: "signed-xdr", + } as any, + responseQueue: responseQueue as any, + sessionTimer, + }); + + expect(result).toEqual({}); + expect(sessionTimer.resetSession).toHaveBeenCalledTimes(1); + expect(response).toHaveBeenCalledWith("signed-xdr"); + expect(callOrder).toEqual(["reset", "response"]); + expect(responseQueue).toEqual([]); + }); + + it("does NOT extend the idle alarm when the uuid is missing", async () => { + const sessionTimer = makeSessionTimer(); + + const result = await handleSignedHwPayload({ + request: { signedPayload: "signed-xdr" } as any, + responseQueue: [] as any, + sessionTimer, + }); + + // A malformed request is never a legitimate signal of user + // presence — only the success branch rearms the idle timer. + expect(sessionTimer.resetSession).not.toHaveBeenCalled(); + expect(result).toEqual({ error: "Transaction not found" }); + }); + + it("does NOT extend the idle alarm when no queue entry matches", async () => { + const sessionTimer = makeSessionTimer(); + + const result = await handleSignedHwPayload({ + request: { uuid: "uuid-missing", signedPayload: "signed-xdr" } as any, + responseQueue: [{ uuid: "uuid-other", response: jest.fn() }] as any, + sessionTimer, + }); + + expect(sessionTimer.resetSession).not.toHaveBeenCalled(); + expect(result).toEqual({ error: "Session timed out" }); + }); + + it("tracks hardware-wallet lock and unlock state transitions", () => { + let state = { session: sessionSlice.reducer(undefined, { type: "init" }) }; + expect(isHardwareWalletLockedSelector(state)).toBe(false); + + state = { + session: sessionSlice.reducer(state.session, lockHardwareWallet()), + }; + expect(isHardwareWalletLockedSelector(state)).toBe(true); + + state = { + session: sessionSlice.reducer(state.session, unlockHardwareWallet()), + }; + expect(isHardwareWalletLockedSelector(state)).toBe(false); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/importHardwareWallet.test.ts b/extension/src/background/messageListener/__tests__/importHardwareWallet.test.ts new file mode 100644 index 0000000000..03d1074c5e --- /dev/null +++ b/extension/src/background/messageListener/__tests__/importHardwareWallet.test.ts @@ -0,0 +1,59 @@ +import { importHardwareWallet } from "../handlers/importHardwareWallet"; + +const mockStoreHardwareWalletAccount = jest.fn().mockResolvedValue(undefined); +const mockGetBipPath = jest.fn().mockResolvedValue("m/44'/148'/0'"); + +jest.mock("../helpers/store-hardware-wallet", () => ({ + storeHardwareWalletAccount: (...args: unknown[]) => + mockStoreHardwareWalletAccount(...args), +})); + +jest.mock("background/helpers/account", () => ({ + getBipPath: (...args: unknown[]) => mockGetBipPath(...args), + getIsHardwareWalletActive: jest.fn().mockResolvedValue(true), +})); + +describe("importHardwareWallet handler", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Regression: HW-only imports flip the session into the "HW-active" + // state where `buildHasPrivateKeySelector` reports the wallet as + // unlocked, but the handler previously did not arm the idle + // auto-lock alarm. HW-only sessions imported via this path had no + // auto-lock at all, contradicting the PR's stated security goal. + it("arms the idle auto-lock alarm after storing the HW account", async () => { + const sessionStore = { + dispatch: jest.fn(), + getState: jest.fn().mockReturnValue({ + session: { publicKey: "GBHW", allAccounts: [], isHardwareWalletLocked: false }, + }), + } as any; + const localStore = { + getItem: jest.fn().mockResolvedValue("hw:GBHW"), + } as any; + const sessionTimer = { + startSession: jest.fn().mockResolvedValue(undefined), + } as any; + + await importHardwareWallet({ + request: { + publicKey: "GBHW", + hardwareWalletType: "Ledger", + bipPath: "m/44'/148'/0'", + } as any, + sessionStore, + localStore, + sessionTimer, + }); + + expect(mockStoreHardwareWalletAccount).toHaveBeenCalledTimes(1); + expect(sessionTimer.startSession).toHaveBeenCalledTimes(1); + // Account is stored before the timer arms so the alarm fires + // relative to an already-active HW session, not a future one. + expect( + mockStoreHardwareWalletAccount.mock.invocationCallOrder[0], + ).toBeLessThan(sessionTimer.startSession.mock.invocationCallOrder[0]); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/loadSaveSettings.test.ts b/extension/src/background/messageListener/__tests__/loadSaveSettings.test.ts index 9f452cbc6c..200246ab85 100644 --- a/extension/src/background/messageListener/__tests__/loadSaveSettings.test.ts +++ b/extension/src/background/messageListener/__tests__/loadSaveSettings.test.ts @@ -1,6 +1,11 @@ import { loadSettings } from "../handlers/loadSettings"; import { saveSettings } from "../handlers/saveSettings"; -import { IS_OPEN_SIDEBAR_BY_DEFAULT_ID } from "constants/localStorageTypes"; +import { + AUTO_LOCK_TIMEOUT_MINUTES_ID, + IS_OPEN_SIDEBAR_BY_DEFAULT_ID, + TEMPORARY_STORE_ID, +} from "constants/localStorageTypes"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; jest.mock("background/helpers/account", () => ({ getAllowList: jest.fn().mockResolvedValue([]), @@ -10,6 +15,7 @@ jest.mock("background/helpers/account", () => ({ getIsHideDustEnabled: jest.fn().mockResolvedValue(true), getIsMemoValidationEnabled: jest.fn().mockResolvedValue(true), getIsNonSSLEnabled: jest.fn().mockResolvedValue(false), + getIsHardwareWalletActive: jest.fn().mockResolvedValue(false), getNetworkDetails: jest.fn().mockResolvedValue({ network: "TESTNET", networkName: "Test Net", @@ -72,10 +78,25 @@ describe("loadSettings isOpenSidebarByDefault", () => { }); describe("saveSettings isOpenSidebarByDefault", () => { + const makeSessionStore = (hashKey: string | null = null) => + ({ + getState: () => ({ + session: hashKey ? { hashKey: { key: hashKey, salt: "s" } } : {}, + }), + }) as any; + + const makeSessionTimer = () => + ({ + resetSession: jest.fn().mockResolvedValue(undefined), + startSession: jest.fn().mockResolvedValue(undefined), + stopSession: jest.fn().mockResolvedValue(undefined), + }) as any; + it("calls setPanelBehavior with the boolean from the request", async () => { const localStore = { getItem: jest.fn().mockImplementation((key: string) => { if (key === IS_OPEN_SIDEBAR_BY_DEFAULT_ID) return Promise.resolve(true); + if (key === AUTO_LOCK_TIMEOUT_MINUTES_ID) return Promise.resolve(15); return Promise.resolve(null); }), setItem: jest.fn().mockResolvedValue(undefined), @@ -86,9 +107,15 @@ describe("saveSettings isOpenSidebarByDefault", () => { isMemoValidationEnabled: true, isHideDustEnabled: true, isOpenSidebarByDefault: true, + autoLockTimeoutMinutes: 15, } as any; - const result = await saveSettings({ request, localStore }); + const result = await saveSettings({ + request, + localStore, + sessionStore: makeSessionStore(), + sessionTimer: makeSessionTimer(), + }); expect(chrome.sidePanel.setPanelBehavior).toHaveBeenCalledWith({ openPanelOnActionClick: true, @@ -101,6 +128,7 @@ describe("saveSettings isOpenSidebarByDefault", () => { getItem: jest.fn().mockImplementation((key: string) => { if (key === IS_OPEN_SIDEBAR_BY_DEFAULT_ID) return Promise.resolve(false); + if (key === AUTO_LOCK_TIMEOUT_MINUTES_ID) return Promise.resolve(15); return Promise.resolve(null); }), setItem: jest.fn().mockResolvedValue(undefined), @@ -111,10 +139,190 @@ describe("saveSettings isOpenSidebarByDefault", () => { isMemoValidationEnabled: true, isHideDustEnabled: true, isOpenSidebarByDefault: false, + autoLockTimeoutMinutes: 15, } as any; - const result = await saveSettings({ request, localStore }); + const result = await saveSettings({ + request, + localStore, + sessionStore: makeSessionStore(), + sessionTimer: makeSessionTimer(), + }); expect(result.isOpenSidebarByDefault).toBe(false); expect(typeof result.isOpenSidebarByDefault).toBe("boolean"); }); }); + +describe("saveSettings autoLockTimeoutMinutes", () => { + const makeSessionStore = (hashKey: string | null = null) => + ({ + getState: () => ({ + session: hashKey ? { hashKey: { key: hashKey, salt: "s" } } : {}, + }), + }) as any; + + const makeSessionTimer = () => + ({ + resetSession: jest.fn().mockResolvedValue(undefined), + startSession: jest.fn().mockResolvedValue(undefined), + stopSession: jest.fn().mockResolvedValue(undefined), + }) as any; + + const makeLocalStore = ( + storedTimeout: number | null = 15, + hasTempStore: boolean = true, + ) => + ({ + getItem: jest.fn().mockImplementation((key: string) => { + if (key === AUTO_LOCK_TIMEOUT_MINUTES_ID) + return Promise.resolve(storedTimeout); + if (key === IS_OPEN_SIDEBAR_BY_DEFAULT_ID) + return Promise.resolve(false); + if (key === TEMPORARY_STORE_ID) { + return Promise.resolve( + hasTempStore ? { "key-id-0": "encrypted-blob" } : null, + ); + } + return Promise.resolve(null); + }), + setItem: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + }) as any; + + const baseRequest = { + isDataSharingAllowed: true, + isMemoValidationEnabled: true, + isHideDustEnabled: true, + isOpenSidebarByDefault: false, + }; + + it("coerces invalid autoLockTimeoutMinutes to the default rather than rejecting", async () => { + // The Preferences ``, + // whose options are populated from `VALID_AUTO_LOCK_TIMEOUT_MINUTES` + // and coerced through `coerceAutoLockTimeoutMinutes` before dispatch. + // We coerce again here as defence-in-depth: a malformed value (e.g. + // from a future client revision or a corrupted message) is clamped to + // the default rather than rejected, since the response type has no + // error variant the popup could surface to the user. + const safeAutoLockTimeoutMinutes = coerceAutoLockTimeoutMinutes( + autoLockTimeoutMinutes, + ); + await localStore.setItem(DATA_SHARING_ID, isDataSharingAllowed); await localStore.setItem(IS_VALIDATING_MEMO_ID, isMemoValidationEnabled); await localStore.setItem(IS_HIDE_DUST_ENABLED_ID, isHideDustEnabled); @@ -37,6 +63,24 @@ export const saveSettings = async ({ IS_OPEN_SIDEBAR_BY_DEFAULT_ID, isOpenSidebarByDefault, ); + await localStore.setItem( + AUTO_LOCK_TIMEOUT_MINUTES_ID, + safeAutoLockTimeoutMinutes, + ); + + // Saving settings is itself a user action, so it counts as activity: + // rearm the idle timer with the new timeout rather than synthesizing + // an immediate lock when the new threshold is shorter than the elapsed + // idle time. Only rearm if the wallet is currently unlocked — for a + // locked wallet there is no session to protect and we'd just leave a + // stray alarm pending. + const hasPrivateKeySelector = buildHasPrivateKeySelector(localStore); + const isUnlocked = await hasPrivateKeySelector( + sessionStore.getState() as SessionState, + ); + if (isUnlocked) { + await sessionTimer.resetSession(); + } // Apply sidebar behavior immediately on Chrome if (chrome.sidePanel?.setPanelBehavior) { @@ -62,5 +106,8 @@ export const saveSettings = async ({ isOpenSidebarByDefault: ((await localStore.getItem(IS_OPEN_SIDEBAR_BY_DEFAULT_ID)) as boolean) ?? false, + autoLockTimeoutMinutes: coerceAutoLockTimeoutMinutes( + await localStore.getItem(AUTO_LOCK_TIMEOUT_MINUTES_ID), + ), }; }; diff --git a/extension/src/background/messageListener/handlers/signOut.ts b/extension/src/background/messageListener/handlers/signOut.ts index 96959d9989..dcac410437 100644 --- a/extension/src/background/messageListener/handlers/signOut.ts +++ b/extension/src/background/messageListener/handlers/signOut.ts @@ -1,7 +1,15 @@ import { Store } from "redux"; +import { SERVICE_TYPES } from "@shared/constants/services"; -import { logOut, publicKeySelector } from "background/ducks/session"; +import { + lockHardwareWallet, + logOut, + publicKeySelector, +} from "background/ducks/session"; import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { SessionTimer } from "background/helpers/session"; +import { broadcastSessionState } from "../helpers/broadcast-session-state"; +import { flushSessionStore } from "background/store"; import { APPLICATION_ID, TEMPORARY_STORE_ID, @@ -10,12 +18,30 @@ import { export const signOut = async ({ localStore, sessionStore, + sessionTimer, }: { localStore: DataStorageAccess; sessionStore: Store; + sessionTimer: SessionTimer; }) => { + // Cancel any pending auto-lock alarm FIRST — the wallet is being + // locked explicitly, so the idle timer no longer needs to fire. + // Clearing before mutating state eliminates the race window where + // a pending alarm could fire `clearSession` on already-cleared + // state and emit a duplicate SESSION_LOCKED broadcast. + await sessionTimer.stopSession(); sessionStore.dispatch(logOut()); + // `logOut` resets session state to `initialState`, which sets + // `isHardwareWalletLocked: false`. The KEY_ID (`hw:…` prefix) is + // intentionally not cleared on sign-out (so the HW account can be + // re-unlocked without re-importing), which means + // `getIsHardwareWalletActive` still reports `true`. Without this + // dispatch, `buildHasPrivateKeySelector`'s HW branch would report + // the wallet UNLOCKED for HW users immediately after sign-out. + sessionStore.dispatch(lockHardwareWallet()); + await flushSessionStore(sessionStore); await localStore.remove(TEMPORARY_STORE_ID); + await broadcastSessionState(SERVICE_TYPES.SESSION_LOCKED); return { publicKey: publicKeySelector(sessionStore.getState()), diff --git a/extension/src/background/messageListener/handlers/userActivity.ts b/extension/src/background/messageListener/handlers/userActivity.ts new file mode 100644 index 0000000000..6c50e1acbc --- /dev/null +++ b/extension/src/background/messageListener/handlers/userActivity.ts @@ -0,0 +1,55 @@ +import { Store } from "redux"; + +import { getIsHardwareWalletActive } from "background/helpers/account"; +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { SessionTimer } from "background/helpers/session"; +import { + hashKeySelector, + isHardwareWalletLockedSelector, + SessionState, +} from "background/ducks/session"; + +/** + * Handle a USER_ACTIVITY ping from an extension page. + * + * Pings originate from `useActivityPing`, which fires (throttled to + * one per 5 s) on direct user input — `mousedown`, `keydown`, + * `touchstart`, `wheel` — inside any Freighter surface (popup, + * sidebar, standalone signing window, grant-access window). Surface + * mounts deliberately do NOT ping: a dApp-spawned signing popup is + * programmatic, not proof of user presence, and a mount-ping would + * let a malicious dApp keep the session alive indefinitely. + * + * The popup-side `isUnlocked` it gates on is a delayed reflection of + * the background's session state (it depends on `loadAccount` having + * dispatched `saveAccount` into the popup's redux store). The + * background is the source of truth, so this handler authoritatively + * re-checks whether the wallet is currently unlocked before rearming + * the idle alarm. A ping that arrives on a locked wallet is dropped: + * there is no live session to extend. + * + * Unlocked = either a hot-wallet session (`hashKey` is set) or an + * active hardware-wallet session that has not been idle-locked. + */ +export const userActivity = async ({ + sessionTimer, + sessionStore, + localStore, +}: { + sessionTimer: SessionTimer; + sessionStore: Store; + localStore: DataStorageAccess; +}) => { + const state = sessionStore.getState() as SessionState; + const hashKey = hashKeySelector(state); + const hotUnlocked = !!hashKey?.key; + const isHwActive = await getIsHardwareWalletActive({ localStore }); + const hwUnlocked = isHwActive && !isHardwareWalletLockedSelector(state); + + if (!hotUnlocked && !hwUnlocked) { + return { ok: false }; + } + + await sessionTimer.resetSession(); + return { ok: true }; +}; diff --git a/extension/src/background/messageListener/helpers/broadcast-session-state.ts b/extension/src/background/messageListener/helpers/broadcast-session-state.ts new file mode 100644 index 0000000000..07cfef423f --- /dev/null +++ b/extension/src/background/messageListener/helpers/broadcast-session-state.ts @@ -0,0 +1,26 @@ +import browser from "webextension-polyfill"; + +import { SERVICE_TYPES } from "@shared/constants/services"; + +// Chrome's runtime.sendMessage rejects with this message whenever no +// extension contexts are listening for the broadcast. That is the +// common case (e.g. the user has every Freighter surface closed when +// the idle alarm fires) and is harmless. Anything else is unexpected +// and worth logging so it doesn't get silently swallowed. +const NO_RECEIVER_PATTERNS = [ + "Could not establish connection", + "Receiving end does not exist", +]; + +export const broadcastSessionState = async ( + type: SERVICE_TYPES.SESSION_LOCKED | SERVICE_TYPES.SESSION_UNLOCKED, +): Promise => { + try { + await browser.runtime.sendMessage({ type }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (!NO_RECEIVER_PATTERNS.some((p) => message.includes(p))) { + console.warn(`broadcastSessionState(${type}) failed:`, e); + } + } +}; diff --git a/extension/src/background/messageListener/helpers/login-all-accounts.ts b/extension/src/background/messageListener/helpers/login-all-accounts.ts index 4a81587e1a..665b5d396c 100644 --- a/extension/src/background/messageListener/helpers/login-all-accounts.ts +++ b/extension/src/background/messageListener/helpers/login-all-accounts.ts @@ -21,6 +21,7 @@ import { storeActiveHashKey, storeEncryptedTemporaryData, } from "background/helpers/session"; +import { flushSessionStore } from "background/store"; import { allAccountsSelector, logIn, @@ -28,6 +29,8 @@ import { } from "background/ducks/session"; import { getStoredAccounts } from "./get-stored-accounts"; import { captureException } from "@sentry/browser"; +import { SERVICE_TYPES } from "@shared/constants/services"; +import { broadcastSessionState } from "./broadcast-session-state"; /* Retrive and store encrypted data for all existing accounts */ export const loginToAllAccounts = async ( @@ -95,6 +98,11 @@ export const loginToAllAccounts = async ( captureException( `Error storing encrypted temporary data: ${JSON.stringify(e)}`, ); + // Rethrow so we don't fall through to startSession() + + // broadcastSessionState(SESSION_UNLOCKED) below, which would tell + // every Freighter surface the wallet is unlocked even though + // clearSession() just wiped it. + throw e; } for (let i = 0; i < keyIdList.length; i += 1) { @@ -132,8 +140,14 @@ export const loginToAllAccounts = async ( } catch (e) { await clearSession({ localStore, sessionStore }); captureException(`Error storing active hash key: ${JSON.stringify(e)}`); + // Rethrow so we don't fall through to startSession() + + // broadcastSessionState(SESSION_UNLOCKED) below — the session was + // just cleared and the wallet must not be reported as unlocked. + throw e; } // start the timer now that we have active private key - sessionTimer.startSession(); + await sessionTimer.startSession(); + await flushSessionStore(sessionStore); + await broadcastSessionState(SERVICE_TYPES.SESSION_UNLOCKED); }; diff --git a/extension/src/background/messageListener/helpers/test-helpers.ts b/extension/src/background/messageListener/helpers/test-helpers.ts index 948510ed3e..f4e4045257 100644 --- a/extension/src/background/messageListener/helpers/test-helpers.ts +++ b/extension/src/background/messageListener/helpers/test-helpers.ts @@ -8,6 +8,11 @@ import { sessionSlice } from "background/ducks/session"; import { dataStorageAccess } from "background/helpers/dataStorageAccess"; import { combineReducers } from "redux"; import { configureStore } from "@reduxjs/toolkit"; +import { + coerceAutoLockTimeoutMinutes, + AutoLockTimeoutMinutes, +} from "@shared/constants/autoLock"; +import { AUTO_LOCK_TIMEOUT_MINUTES_ID } from "constants/localStorageTypes"; const mockStore: Record = {}; const mockStorageApi = { @@ -81,18 +86,36 @@ const mockKeyManager = new KeyManager({ mockKeyManager.registerEncrypter(ScryptEncrypter); const MOCK_TIMER_DURATION = 60 * 24; + +/** + * Test double for `SessionTimer`. Reads the auto-lock timeout from the + * mock storage on each `resetSession()` / `startSession()` call so + * tests can simulate settings changes via `mockStore`. Tracks the + * number of resets/stops to allow assertions on alarm behavior. + */ class MockBrowserAlarm { duration = 1000 * 60 * MOCK_TIMER_DURATION; runningTimeout: null | ReturnType = null; callback: () => void; + resetCount = 0; + stopCount = 0; + lastTimeoutMinutes: AutoLockTimeoutMinutes | null = null; constructor(callback: () => unknown, duration?: number) { this.duration = duration || this.duration; this.callback = callback; } - startSession() { - this.duration = 1000 * 60 * MOCK_TIMER_DURATION; + async startSession() { + await this.resetSession(); + } + + async resetSession() { + const stored = mockStore[AUTO_LOCK_TIMEOUT_MINUTES_ID]; + const minutes = coerceAutoLockTimeoutMinutes(stored); + this.lastTimeoutMinutes = minutes; + this.duration = 1000 * 60 * minutes; + this.resetCount += 1; if (this.runningTimeout) clearTimeout(this.runningTimeout); this.runningTimeout = setTimeout(() => { @@ -101,6 +124,14 @@ class MockBrowserAlarm { } }, this.duration); } + + async stopSession() { + this.stopCount += 1; + if (this.runningTimeout) { + clearTimeout(this.runningTimeout); + this.runningTimeout = null; + } + } } export { diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 4d1bc3fed9..b03fba7c31 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -64,6 +64,7 @@ import { loadLastUsedAccount } from "./handlers/loadLastAccountUsed"; import { signOut } from "./handlers/signOut"; import { saveAllowList } from "./handlers/saveAllowList"; import { saveSettings } from "./handlers/saveSettings"; +import { userActivity } from "./handlers/userActivity"; import { saveExperimentalFeatures } from "./handlers/saveExperimentalFeatures"; import { loadSettings } from "./handlers/loadSettings"; import { getCachedAssetIconList } from "./handlers/getCachedAssetIconList"; @@ -142,16 +143,28 @@ export const popupMessageListener = ( localStore: DataStorageAccess, keyManager: KeyManager, sessionTimer: SessionTimer, - sender: { tab?: unknown; id?: string }, + sender: { tab?: unknown; id?: string; url?: string }, ) => { const currentState = sessionStore.getState(); const publicKey = publicKeySelector(currentState); - // Content scripts (dapp pages) always carry sender.tab; extension pages do not. - // Also verify the message originates from this extension (sender.id matches), - // guarding against other extensions calling popupMessageListener handlers. + // Content scripts (dapp pages) carry `sender.tab` with a non-extension + // `sender.url`. Extension pages may also carry `sender.tab` when they + // live in their own tab/window (e.g. dApp-spawned signing popups + // created via `browser.windows.create`, fullscreen mode); those still + // have an extension-origin `sender.url`. So the right check is: + // `sender.id` matches our extension AND either there is no tab + // (browser-action popup, sidepanel) OR the URL is on our extension + // origin (popup window, options page, fullscreen). + const extensionOrigin = browser?.runtime?.getURL?.("") ?? ""; + const isFromOwnExtension = + !sender.id || sender.id === browser?.runtime?.id; + const isExtensionUrl = + !!extensionOrigin && + typeof sender.url === "string" && + sender.url.startsWith(extensionOrigin); const isFromExtensionPage = - !sender.tab && (!sender.id || sender.id === browser?.runtime?.id); + isFromOwnExtension && (!sender.tab || isExtensionUrl); if ( request.activePublicKey && @@ -197,6 +210,7 @@ export const popupMessageListener = ( request, localStore, sessionStore, + sessionTimer, }); } case SERVICE_TYPES.MAKE_ACCOUNT_ACTIVE: { @@ -311,6 +325,7 @@ export const popupMessageListener = ( return handleSignedHwPayload({ request, responseQueue, + sessionTimer, }); } case SERVICE_TYPES.ADD_TOKEN: { @@ -401,6 +416,7 @@ export const popupMessageListener = ( return signOut({ localStore, sessionStore, + sessionTimer, }); } case SERVICE_TYPES.SAVE_ALLOWLIST: { @@ -414,6 +430,8 @@ export const popupMessageListener = ( return saveSettings({ request, localStore, + sessionStore, + sessionTimer, }); } case SERVICE_TYPES.SAVE_EXPERIMENTAL_FEATURES: { @@ -624,6 +642,11 @@ export const popupMessageListener = ( })(); } + case SERVICE_TYPES.USER_ACTIVITY: { + if (!isFromExtensionPage) return { error: "Unauthorized" }; + return userActivity({ sessionTimer, sessionStore, localStore }); + } + default: return { error: "Message type not supported" }; } diff --git a/extension/src/background/store.ts b/extension/src/background/store.ts index 9d6e18ac70..94b7e6936b 100644 --- a/extension/src/background/store.ts +++ b/extension/src/background/store.ts @@ -1,4 +1,4 @@ -import { combineReducers } from "redux"; +import { combineReducers, Store } from "redux"; import { configureStore } from "@reduxjs/toolkit"; import { sessionSlice } from "background/ducks/session"; @@ -29,11 +29,15 @@ const rootReducer = combineReducers({ session: sessionSlice.reducer, }); -type RootState = ReturnType; - -function saveStore(state: RootState) { - const serializedState = JSON.stringify(state); - sessionStore.setItem(REDUX_STORE_KEY, serializedState); +export async function flushSessionStore(storeToFlush: Store): Promise { + try { + const serializedState = JSON.stringify(storeToFlush.getState()); + await sessionStore.setItem(REDUX_STORE_KEY, serializedState); + } catch (_error) { + // Best-effort only: Firefox and some test environments do not expose + // storage.session, and the existing subscribe-based persistence also + // ignores those failures. + } } const store = configureStore({ @@ -56,7 +60,9 @@ export const buildStore = async () => { preloadedState: reduxState, }); - hydratedStore.subscribe(() => saveStore(hydratedStore.getState())); + hydratedStore.subscribe(() => { + void flushSessionStore(hydratedStore); + }); return hydratedStore; } diff --git a/extension/src/constants/localStorageTypes.ts b/extension/src/constants/localStorageTypes.ts index b24419877b..764180f147 100644 --- a/extension/src/constants/localStorageTypes.ts +++ b/extension/src/constants/localStorageTypes.ts @@ -34,3 +34,4 @@ export const IS_OPEN_SIDEBAR_BY_DEFAULT_ID = "isOpenSidebarByDefault"; export const METRICS_USER_ID = "metrics_user_id"; export const RECENT_PROTOCOLS = "recentProtocols"; export const HAS_SEEN_DISCOVER_WELCOME = "hasSeenDiscoverWelcome"; +export const AUTO_LOCK_TIMEOUT_MINUTES_ID = "autoLockTimeoutMinutes"; diff --git a/extension/src/helpers/__tests__/useGetAppData.test.tsx b/extension/src/helpers/__tests__/useGetAppData.test.tsx new file mode 100644 index 0000000000..4d4f1965f2 --- /dev/null +++ b/extension/src/helpers/__tests__/useGetAppData.test.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { Provider } from "react-redux"; +import { renderHook, act } from "@testing-library/react"; + +import { useGetAppData, AppDataType } from "../hooks/useGetAppData"; +import { makeDummyStore } from "popup/__testHelpers__"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import { ROUTES } from "popup/constants/routes"; +import * as ApiInternal from "@shared/api/internal"; + +const renderUseGetAppData = (preloadedAuthState: any) => { + const store = makeDummyStore({ + auth: { + allAccounts: [], + publicKey: preloadedAuthState.publicKey ?? "", + hasPrivateKey: preloadedAuthState.hasPrivateKey ?? false, + applicationState: + preloadedAuthState.applicationState ?? + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + accountStatus: "IDLE", + }, + settings: {}, + }); + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return renderHook(() => useGetAppData(), { wrapper: Wrapper }); +}; + +describe("useGetAppData — cache fast path requires hasPrivateKey (Bug A regression)", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("does NOT short-circuit on cached account when hasPrivateKey is false; re-routes to unlockAccount", async () => { + const loadAccountSpy = jest + .spyOn(ApiInternal, "loadAccount") + .mockResolvedValueOnce({ + publicKey: "GCACHED", + hasPrivateKey: false, + allAccounts: [], + applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + } as any); + jest.spyOn(ApiInternal, "loadSettings").mockResolvedValueOnce({} as any); + + const { result } = renderUseGetAppData({ + publicKey: "GCACHED", + hasPrivateKey: false, + applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + }); + + let payload: any; + await act(async () => { + payload = await result.current.fetchData(true); + }); + + // Cache had publicKey but no hasPrivateKey — fast path must NOT short-circuit. + expect(loadAccountSpy).toHaveBeenCalled(); + expect(payload?.type).toBe(AppDataType.REROUTE); + expect(payload?.routeTarget).toBe(ROUTES.unlockAccount); + }); + + it("does short-circuit on cached account when both publicKey and hasPrivateKey are set", async () => { + const loadAccountSpy = jest.spyOn(ApiInternal, "loadAccount"); + + const { result } = renderUseGetAppData({ + publicKey: "GCACHED", + hasPrivateKey: true, + applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + }); + + let payload: any; + await act(async () => { + payload = await result.current.fetchData(true); + }); + + expect(loadAccountSpy).not.toHaveBeenCalled(); + expect(payload?.type).toBe(AppDataType.RESOLVED); + expect(payload?.account?.publicKey).toBe("GCACHED"); + }); +}); diff --git a/extension/src/helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx b/extension/src/helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx index 9b18836286..57b0ec5c4c 100644 --- a/extension/src/helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx +++ b/extension/src/helpers/__tests__/useGetAssetDomainsWithBalances.test.tsx @@ -78,6 +78,7 @@ describe("useGetAssetDomainsWithBalances (cached path)", () => { const preloadedState = { auth: { publicKey: TEST_PUBLIC_KEY, + hasPrivateKey: true, }, cache: { balanceData: { diff --git a/extension/src/helpers/hooks/useGetAppData.tsx b/extension/src/helpers/hooks/useGetAppData.tsx index febaf398da..37b76efd10 100644 --- a/extension/src/helpers/hooks/useGetAppData.tsx +++ b/extension/src/helpers/hooks/useGetAppData.tsx @@ -55,7 +55,11 @@ function useGetAppData() { dispatch({ type: "FETCH_DATA_START" }); reduxDispatch(saveApplicationState(APPLICATION_STATE.APPLICATION_LOADING)); try { - if (useCache && currentAccount.publicKey) { + if ( + useCache && + currentAccount.publicKey && + currentAccount.hasPrivateKey + ) { const payload = { type: "resolved", account: currentAccount, @@ -74,6 +78,7 @@ function useGetAppData() { if ( !account.publicKey || + !account.hasPrivateKey || account.applicationState === APPLICATION_STATE.APPLICATION_STARTED ) { const hasOnboarded = diff --git a/extension/src/popup/App.tsx b/extension/src/popup/App.tsx index c92ba1b1c1..02acb36279 100755 --- a/extension/src/popup/App.tsx +++ b/extension/src/popup/App.tsx @@ -15,6 +15,7 @@ import { reducer as cache } from "popup/ducks/cache"; import { reducer as remoteConfig } from "popup/ducks/remoteConfig"; import { ErrorTracking } from "popup/components/ErrorTracking"; import { AccountMismatch } from "popup/components/AccountMismatch"; +import { ActivityTracker } from "popup/components/ActivityTracker"; import { MaintenanceScreen } from "popup/components/MaintenanceScreen"; import { useRemoteConfig } from "popup/helpers/hooks/useRemoteConfig"; import { maintenanceScreenSelector } from "popup/ducks/remoteConfig"; @@ -66,6 +67,7 @@ export const App = () => ( + diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index 457e8e5257..cbc23c9a72 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -50,6 +50,7 @@ import { Settings } from "popup/views/Settings"; import { Preferences } from "popup/views/Preferences"; import { Security } from "popup/views/Security"; import { AdvancedSettings } from "popup/views/AdvancedSettings"; +import { AutoLockTimer } from "popup/views/AutoLockTimer"; import { About } from "popup/views/About"; import { Send } from "popup/views/Send"; import { ManageAssets } from "popup/views/ManageAssets"; @@ -66,6 +67,7 @@ import { ConfirmSidebarRequest } from "popup/views/ConfirmSidebarRequest"; import { DEV_SERVER } from "@shared/constants/services"; import { isSidebarMode } from "popup/helpers/isSidebarMode"; import { SidebarSigningListener } from "popup/components/SidebarSigningListener"; +import { SessionLockListener } from "popup/components/SessionLockListener"; import { SettingsState } from "@shared/api/types"; import { SignMessage } from "./views/SignMessage"; @@ -173,6 +175,7 @@ const Layout = () => { export const Router = () => ( + {isSidebarMode() && } }> @@ -292,6 +295,10 @@ export const Router = () => ( path={ROUTES.advancedSettings} element={} > + } + > } /> } /> diff --git a/extension/src/popup/components/ActivityTracker/index.tsx b/extension/src/popup/components/ActivityTracker/index.tsx new file mode 100644 index 0000000000..a3ce98a991 --- /dev/null +++ b/extension/src/popup/components/ActivityTracker/index.tsx @@ -0,0 +1,17 @@ +import { useSelector } from "react-redux"; + +import { hasPrivateKeySelector } from "popup/ducks/accountServices"; +import { useActivityPing } from "popup/helpers/hooks/useActivityPing"; + +/** + * Thin redux-aware wrapper that drives the idle activity ping. Lives + * inside `` (mounted by `popup/App.tsx`) so it has access to + * `hasPrivateKeySelector`, which is the canonical "wallet is unlocked" + * signal across popup, sidebar, and standalone signing/grant-access + * windows — all of which mount the same App. + */ +export const ActivityTracker = () => { + const isUnlocked = useSelector(hasPrivateKeySelector); + useActivityPing(isUnlocked); + return null; +}; diff --git a/extension/src/popup/components/SessionLockListener/index.tsx b/extension/src/popup/components/SessionLockListener/index.tsx new file mode 100644 index 0000000000..69a4a27031 --- /dev/null +++ b/extension/src/popup/components/SessionLockListener/index.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef } from "react"; +import { useDispatch } from "react-redux"; +import { useLocation, useNavigate } from "react-router-dom"; +import browser from "webextension-polyfill"; + +import { loadAccount } from "@shared/api/internal"; +import { SERVICE_TYPES } from "@shared/constants/services"; +import { ROUTES } from "popup/constants/routes"; +import { lockAccount, saveAccount } from "popup/ducks/accountServices"; + +/** + * Listens for the background's `SESSION_LOCKED` broadcast (fired when + * the idle auto-lock alarm elapses) and flips the popup over to the + * unlock screen immediately, instead of leaving stale account/asset + * views on screen until the next data refetch. + * + * Mounted inside `` so it can use `useNavigate` / + * `useLocation`. Every Freighter UI surface (popup, sidebar, standalone + * signing window, grant-access window) renders the same `` → + * `` tree, so one mount covers all of them. + * + * When navigating to the unlock screen we preserve the current + * `location` (as `state.from`) and `location.search`, mirroring the + * pattern used by `` / `` reroutes. After + * a successful unlock, `` reads `state.from.pathname` + + * `location.search` and returns the user to the interrupted flow + * (e.g. `/grant-access?...`, `/sign-transaction?...`) rather than + * stranding them on the default account page. + */ +export const SessionLockListener = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const location = useLocation(); + + // Mirror `location` into a ref so the `runtime.onMessage` listener + // can read the latest value without being a dependency of the + // effect that registers it. Without this the listener would be + // re-registered on every navigation — functionally fine (the + // cleanup unregisters first), but unnecessary churn on a hot path. + const locationRef = useRef(location); + useEffect(() => { + locationRef.current = location; + }, [location]); + + useEffect(() => { + // IMPORTANT: this handler must be a *synchronous* function. Every + // Freighter UI surface (popup, sidebar, fullscreen) registers a + // `runtime.onMessage` listener, and `browser.runtime.sendMessage` + // broadcasts to every extension context except the sender. If this + // handler were `async`, it would always return a Promise — which + // Chrome interprets as "this listener will produce the response" — + // and could win the race against the background's own handler for + // any unrelated request (e.g. `LOAD_ACCOUNT`, + // `GET_IS_ACCOUNT_MISMATCH`) sent by another surface. The sender + // would then receive `undefined` from this listener instead of the + // real background payload, surfacing as crashes like + // "Cannot read properties of null (reading 'publicKey')". + // + // To stay out of the response slot for unrelated messages we + // return `undefined` synchronously below. For our two broadcast + // types we still don't claim the response slot (the background's + // broadcast doesn't await any reply); the SESSION_UNLOCKED + // `loadAccount` round-trip runs as a fire-and-forget side effect. + const handler = (message: unknown) => { + if (typeof message !== "object" || message === null) return undefined; + const { type } = message as { type?: unknown }; + if ( + type !== SERVICE_TYPES.SESSION_LOCKED && + type !== SERVICE_TYPES.SESSION_UNLOCKED + ) { + return undefined; + } + + const currentLocation = locationRef.current; + + if (type === SERVICE_TYPES.SESSION_LOCKED) { + // Always flip the popup's redux to locked state — even if we're + // already on /unlock-account. Without this dispatch the surface + // keeps `hasPrivateKey: true` in redux while the background + // session is locked, and `` continues sending + // `USER_ACTIVITY` pings (which are no-ops once the background + // is locked, but still wasted IPC and a misleading signal). + dispatch(lockAccount()); + // Skip the navigate only when we're already on the unlock + // screen. That avoids clobbering an existing `state.from` set + // by an earlier reroute (e.g. a sign-transaction flow that + // already rerouted here and stored the original destination). + if (currentLocation.pathname === ROUTES.unlockAccount) return undefined; + navigate(`${ROUTES.unlockAccount}${currentLocation.search}`, { + state: { from: currentLocation }, + }); + return undefined; + } + + // SESSION_UNLOCKED. Refresh this surface's auth state so passive + // surfaces (e.g. sidebar parked on /unlock-account) reflect the + // unlocked wallet. We deliberately do NOT navigate here: + // `runtime.sendMessage` is broadcast by the background to every + // extension context including the popup that *initiated* the + // unlock via `confirmPassword`, so navigating from this listener + // would race the unlock view's own post-submit navigation (and + // in the grant-access flow would land the user on /account + // instead of /grant-access). The unlock screens watch + // `hasPrivateKey` and navigate themselves once auth state + // flips, which covers both the active and passive surfaces. + void (async () => { + try { + const account = await loadAccount(); + // Guard against a missing/undefined background response. MV3 + // service-worker restarts and transient handler errors can + // resolve `loadAccount()` to `undefined`; dispatching that + // into `saveAccount` hits the destructuring reducer and + // throws `TypeError: Cannot destructure property + // 'hasPrivateKey' of 'undefined'`. Treat as a no-op — the + // next genuine auth event will refresh the surface. + if (!account) return; + dispatch(saveAccount(account)); + } catch (e) { + // An uncaught rejection here becomes an unhandled promise + // rejection that crashes the surface. Log and drop — the + // background remains the source of truth. + console.error("SessionLockListener: loadAccount failed", e); + } + })(); + return undefined; + }; + + browser.runtime.onMessage.addListener(handler); + return () => { + browser.runtime.onMessage.removeListener(handler); + }; + }, [dispatch, navigate]); + + return null; +}; diff --git a/extension/src/popup/components/__tests__/SearchAsset.test.tsx b/extension/src/popup/components/__tests__/SearchAsset.test.tsx index 75a1fda3ff..27b84cd865 100644 --- a/extension/src/popup/components/__tests__/SearchAsset.test.tsx +++ b/extension/src/popup/components/__tests__/SearchAsset.test.tsx @@ -32,6 +32,7 @@ describe("SearchAsset", () => { error: null, applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "G1", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { @@ -112,6 +113,7 @@ describe("SearchAsset", () => { error: null, applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "G1", + hasPrivateKey: true, allAccounts: mockAccounts, balances: mockBalances, }, diff --git a/extension/src/popup/components/__tests__/SessionLockListener.test.tsx b/extension/src/popup/components/__tests__/SessionLockListener.test.tsx new file mode 100644 index 0000000000..17e9d174b0 --- /dev/null +++ b/extension/src/popup/components/__tests__/SessionLockListener.test.tsx @@ -0,0 +1,275 @@ +import React from "react"; +import { render, act } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import { + MemoryRouter, + Routes, + Route, + useLocation, +} from "react-router-dom"; +import browser from "webextension-polyfill"; + +import { loadAccount } from "@shared/api/internal"; +import { SERVICE_TYPES } from "@shared/constants/services"; +import { ROUTES } from "popup/constants/routes"; +import { reducer as authReducer } from "popup/ducks/accountServices"; +import { SessionLockListener } from "../SessionLockListener"; + +type RuntimeHandler = (message: unknown) => void | Promise; + +const listeners: RuntimeHandler[] = []; +const mockLoadAccount = loadAccount as jest.MockedFunction; + +jest.mock("webextension-polyfill", () => ({ + runtime: { + onMessage: { + addListener: jest.fn((h: RuntimeHandler) => listeners.push(h)), + removeListener: jest.fn((h: RuntimeHandler) => { + const idx = listeners.indexOf(h); + if (idx >= 0) listeners.splice(idx, 1); + }), + }, + }, +})); + +jest.mock("@shared/api/internal", () => ({ + loadAccount: jest.fn(), +})); + +const makeStore = () => + configureStore({ + reducer: combineReducers({ auth: authReducer }), + preloadedState: { + auth: { + allAccounts: [{ publicKey: "GBTEST" } as any], + migratedAccounts: [], + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + hasPrivateKey: true, + publicKey: "GBTEST", + connectingWalletType: "NONE", + bipPath: "m/0", + tokenIdList: [], + error: "", + accountStatus: "IDLE", + isAccountMismatch: false, + }, + } as any, + }); + +const loadedAccount = { + allAccounts: [{ publicKey: "GBUPDATED" } as any], + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + bipPath: "m/44'/148'/1'", + hasPrivateKey: true, + publicKey: "GBUPDATED", + tokenIdList: ["token2"], +} as Awaited>; + +let lastLocation: ReturnType | null = null; +const LocationSpy = () => { + lastLocation = useLocation(); + return null; +}; + +const renderListener = ( + initialEntry: string = "/", + store = makeStore(), +) => + render( + + + + + + + + + , + ); + +const emitMessage = async (message: unknown) => { + await act(async () => { + await listeners[listeners.length - 1](message); + }); +}; + +const callListenerSync = (message: unknown) => + listeners[listeners.length - 1](message); + +describe("SessionLockListener", () => { + beforeEach(() => { + listeners.length = 0; + lastLocation = null; + jest.clearAllMocks(); + mockLoadAccount.mockResolvedValue(loadedAccount); + }); + + it("registers a runtime.onMessage listener on mount and removes it on unmount", () => { + const { unmount } = renderListener(); + + expect(browser.runtime.onMessage.addListener).toHaveBeenCalled(); + expect(listeners.length).toBeGreaterThanOrEqual(1); + + unmount(); + expect(browser.runtime.onMessage.removeListener).toHaveBeenCalled(); + expect(listeners).toHaveLength(0); + }); + + it("dispatches lockAccount and navigates to unlockAccount on SESSION_LOCKED", async () => { + const store = makeStore(); + renderListener("/", store); + + await emitMessage({ type: SERVICE_TYPES.SESSION_LOCKED }); + + const { auth } = store.getState(); + expect(auth.hasPrivateKey).toBe(false); + expect(auth.publicKey).toBe(""); + expect(auth.allAccounts).toEqual([]); + expect(lastLocation?.pathname).toBe(ROUTES.unlockAccount); + }); + + it("preserves the originating route as state.from and forwards location.search", async () => { + const store = makeStore(); + const originalEntry = `${ROUTES.grantAccess}?domain=example.com&public=GA1`; + renderListener(originalEntry, store); + + await emitMessage({ type: SERVICE_TYPES.SESSION_LOCKED }); + + expect(lastLocation?.pathname).toBe(ROUTES.unlockAccount); + expect(lastLocation?.search).toBe("?domain=example.com&public=GA1"); + const state = lastLocation?.state as { from?: { pathname?: string } }; + expect(state?.from?.pathname).toBe(ROUTES.grantAccess); + }); + + it("dispatches lockAccount but does NOT re-navigate when already on the unlock screen", async () => { + const store = makeStore(); + renderListener(ROUTES.unlockAccount, store); + const initialState = lastLocation?.state; + + await emitMessage({ type: SERVICE_TYPES.SESSION_LOCKED }); + + // Redux is still flipped to locked so passive surfaces and any + // mounted see the correct auth state — only the + // navigate is suppressed (to avoid clobbering an existing + // `state.from` set by an earlier reroute). + expect(store.getState().auth.hasPrivateKey).toBe(false); + expect(lastLocation?.pathname).toBe(ROUTES.unlockAccount); + expect(lastLocation?.state).toBe(initialState); + }); + + it("loads and saves account data on SESSION_UNLOCKED", async () => { + const store = makeStore(); + renderListener("/", store); + + await emitMessage({ type: SERVICE_TYPES.SESSION_UNLOCKED }); + + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + const { auth } = store.getState(); + expect(auth.hasPrivateKey).toBe(true); + expect(auth.publicKey).toBe("GBUPDATED"); + expect(auth.allAccounts).toEqual([{ publicKey: "GBUPDATED" }]); + }); + + it("does not navigate from any route on SESSION_UNLOCKED (unlock views navigate themselves via redux state)", async () => { + renderListener(ROUTES.unlockAccount); + + await emitMessage({ type: SERVICE_TYPES.SESSION_UNLOCKED }); + + expect(lastLocation?.pathname).toBe(ROUTES.unlockAccount); + }); + + it("does not navigate from a non-lock route on SESSION_UNLOCKED", async () => { + renderListener(ROUTES.settings); + + await emitMessage({ type: SERVICE_TYPES.SESSION_UNLOCKED }); + + expect(lastLocation?.pathname).toBe(ROUTES.settings); + }); + + it("ignores unrelated messages", async () => { + const store = makeStore(); + renderListener("/", store); + + await emitMessage({ type: SERVICE_TYPES.LOAD_ACCOUNT }); + await emitMessage("not an object"); + await emitMessage(null); + + const { auth } = store.getState(); + expect(auth.hasPrivateKey).toBe(true); + expect(auth.publicKey).toBe("GBTEST"); + expect(lastLocation?.pathname).toBe("/"); + }); + + // Regression: a flaky background or a transient MV3 service-worker + // restart can make `loadAccount()` resolve with `undefined`. + // Dispatching that into `saveAccount` previously hit the + // destructuring reducer and threw `TypeError: Cannot destructure + // property 'hasPrivateKey' of 'undefined'`, crashing the surface. + it("treats an undefined loadAccount result as a no-op on SESSION_UNLOCKED", async () => { + const store = makeStore(); + mockLoadAccount.mockResolvedValueOnce( + undefined as unknown as Awaited>, + ); + renderListener("/", store); + + await emitMessage({ type: SERVICE_TYPES.SESSION_UNLOCKED }); + + const { auth } = store.getState(); + // State is unchanged from the preloaded value — no crash, no + // partial application of the response. + expect(auth.publicKey).toBe("GBTEST"); + expect(auth.hasPrivateKey).toBe(true); + }); + + // Regression: an uncaught rejection inside the fire-and-forget + // SESSION_UNLOCKED handler became an unhandled promise rejection + // that could crash the surface in production. + it("does not throw when loadAccount rejects on SESSION_UNLOCKED", async () => { + const store = makeStore(); + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + mockLoadAccount.mockRejectedValueOnce(new Error("network down")); + renderListener("/", store); + + await expect( + emitMessage({ type: SERVICE_TYPES.SESSION_UNLOCKED }), + ).resolves.not.toThrow(); + expect(store.getState().auth.publicKey).toBe("GBTEST"); + consoleSpy.mockRestore(); + }); + + // Regression: when multiple Freighter surfaces are open, the background + // and every popup-side SessionLockListener share `runtime.onMessage`. + // Returning a Promise (e.g. from an `async` handler) tells Chrome + // "this listener will respond" and can win the race against the + // background handler for unrelated requests, leaking `undefined` to + // the sender. The handler must therefore return `undefined` + // synchronously for messages it does not own. + it("returns undefined synchronously for unrelated messages so it does not claim the response slot", () => { + renderListener(); + + expect(callListenerSync({ type: SERVICE_TYPES.LOAD_ACCOUNT })).toBeUndefined(); + expect( + callListenerSync({ type: SERVICE_TYPES.GET_IS_ACCOUNT_MISMATCH }), + ).toBeUndefined(); + expect(callListenerSync("not an object")).toBeUndefined(); + expect(callListenerSync(null)).toBeUndefined(); + }); + + it("returns undefined synchronously for SESSION_LOCKED / SESSION_UNLOCKED too (background broadcast doesn't await a reply)", () => { + renderListener("/", makeStore()); + + let lockedResult: unknown; + let unlockedResult: unknown; + act(() => { + lockedResult = callListenerSync({ type: SERVICE_TYPES.SESSION_LOCKED }); + unlockedResult = callListenerSync({ + type: SERVICE_TYPES.SESSION_UNLOCKED, + }); + }); + expect(lockedResult).toBeUndefined(); + expect(unlockedResult).toBeUndefined(); + }); +}); diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 0197e596b9..35821e541d 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -34,6 +34,7 @@ export const METRIC_NAMES = { viewAbout: "loaded screen: about", viewManageAssetsLists: "loaded screen: manage assets lists", viewAdvancedSettings: "loaded screen: advanced settings", + viewAutoLockTimer: "loaded screen: auto-lock timer", viewSendPayment: "loaded screen: send payment", sendPaymentTo: "loaded screen: send payment to", diff --git a/extension/src/popup/constants/routes.ts b/extension/src/popup/constants/routes.ts index 5170ee0cbc..8431c95a71 100644 --- a/extension/src/popup/constants/routes.ts +++ b/extension/src/popup/constants/routes.ts @@ -36,6 +36,7 @@ export enum ROUTES { manageAssetsLists = "/settings/manage-assets-lists", manageAssetsListsModifyAssetList = "/settings/manage-assets-lists/modify-asset-list", advancedSettings = "/settings/advanced-settings", + autoLockTimer = "/settings/security/auto-lock-timer", addFunds = "/add-funds", addCollectibles = "/add-collectibles", diff --git a/extension/src/popup/ducks/__tests__/accountServices.test.ts b/extension/src/popup/ducks/__tests__/accountServices.test.ts new file mode 100644 index 0000000000..1ff9aa5713 --- /dev/null +++ b/extension/src/popup/ducks/__tests__/accountServices.test.ts @@ -0,0 +1,46 @@ +import { combineReducers, configureStore } from "@reduxjs/toolkit"; + +import { + reducer as authReducer, + lockAccount, +} from "../accountServices"; + +const makeStore = () => + configureStore({ + reducer: combineReducers({ auth: authReducer }), + preloadedState: { + auth: { + allAccounts: [ + { publicKey: "GBTEST", name: "Account 1", imported: false } as any, + ], + migratedAccounts: [], + applicationState: "MNEMONIC_PHRASE_CONFIRMED", + hasPrivateKey: true, + publicKey: "GBTEST", + connectingWalletType: "NONE", + bipPath: "m/44'/148'/0'", + tokenIdList: ["token1"], + error: "", + accountStatus: "IDLE", + isAccountMismatch: false, + }, + } as any, + }); + +describe("accountServices lockAccount reducer", () => { + it("clears private-key-derived state but preserves applicationState", () => { + const store = makeStore(); + + store.dispatch(lockAccount()); + + const { auth } = store.getState(); + expect(auth.hasPrivateKey).toBe(false); + expect(auth.publicKey).toBe(""); + expect(auth.allAccounts).toEqual([]); + expect(auth.bipPath).toBe(""); + expect(auth.tokenIdList).toEqual([]); + // Left intact so `` still renders `` + // instead of redirecting to ``. + expect(auth.applicationState).toBe("MNEMONIC_PHRASE_CONFIRMED"); + }); +}); diff --git a/extension/src/popup/ducks/__tests__/settings.test.ts b/extension/src/popup/ducks/__tests__/settings.test.ts new file mode 100644 index 0000000000..7cd929c163 --- /dev/null +++ b/extension/src/popup/ducks/__tests__/settings.test.ts @@ -0,0 +1,78 @@ +import { combineReducers, configureStore } from "@reduxjs/toolkit"; + +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; +import { saveSettings as saveSettingsService } from "@shared/api/internal"; +import { SettingsState } from "@shared/api/types"; +import { reducer as authReducer } from "../accountServices"; +import { reducer as settingsReducer, saveSettings } from "../settings"; + +jest.mock("@shared/api/internal", () => ({ + ...jest.requireActual("@shared/api/internal"), + saveSettings: jest.fn(), +})); + +const makeSettingsResponse = () => ({ + allowList: {}, + isDataSharingAllowed: true, + isMemoValidationEnabled: true, + isHideDustEnabled: true, + isOpenSidebarByDefault: false, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, + networkDetails: { + network: "TESTNET", + networkName: "Testnet", + networkUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }, + networksList: [], + isRpcHealthy: true, + isSorobanPublicEnabled: false, + settingsState: SettingsState.SUCCESS, + userNotification: { enabled: false, message: "" }, +}); + +const makeStore = () => + configureStore({ + reducer: combineReducers({ auth: authReducer, settings: settingsReducer }), + preloadedState: { + auth: { + allAccounts: [], + migratedAccounts: [], + applicationState: "APPLICATION_STARTED", + hasPrivateKey: true, + publicKey: "GBTEST", + connectingWalletType: "NONE", + bipPath: "", + tokenIdList: [], + error: "", + accountStatus: "IDLE", + isAccountMismatch: false, + }, + } as any, + }); + +const request = { + isDataSharingAllowed: true, + isMemoValidationEnabled: true, + isHideDustEnabled: true, + isOpenSidebarByDefault: false, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, +}; + +describe("settings saveSettings thunk", () => { + beforeEach(() => { + (saveSettingsService as jest.Mock).mockReset(); + }); + + it("leaves the popup auth state untouched after a successful save", async () => { + // Background no longer reports an immediate-lock back to the popup — + // saving settings is treated as user activity and the timer is + // simply rearmed. The auth slice should remain unlocked. + (saveSettingsService as jest.Mock).mockResolvedValue(makeSettingsResponse()); + const store = makeStore(); + + await store.dispatch(saveSettings(request) as any); + + expect(store.getState().auth.hasPrivateKey).toBe(true); + }); +}); diff --git a/extension/src/popup/ducks/accountServices.ts b/extension/src/popup/ducks/accountServices.ts index a397ebafd6..336a8e8bcc 100644 --- a/extension/src/popup/ducks/accountServices.ts +++ b/extension/src/popup/ducks/accountServices.ts @@ -603,6 +603,23 @@ const authSlice = createSlice({ saveApplicationState(state, action) { state.applicationState = action.payload; }, + /** + * Called when the background broadcasts that the wallet has been + * locked (e.g., the idle auto-lock alarm fired). Mirrors the + * background's `timeoutAccountAccess` reset on the popup side so + * that route guards (`VerifiedAccountRoute`, `useGetAppData`) see + * `hasPrivateKey: false` immediately instead of after the next + * data refetch. Public/private-key-derived state is cleared; + * `applicationState` is left untouched so `` + * still renders `` rather than ``. + */ + lockAccount(state) { + state.hasPrivateKey = false; + state.publicKey = ""; + state.allAccounts = []; + state.bipPath = ""; + state.tokenIdList = []; + }, }, extraReducers: (builder) => { builder.addCase(createAccount.fulfilled, (state, action) => { @@ -990,6 +1007,7 @@ export const { saveAccount, saveAccountError, saveApplicationState, + lockAccount, } = authSlice.actions; export { reducer }; diff --git a/extension/src/popup/ducks/settings.ts b/extension/src/popup/ducks/settings.ts index 7da17788dc..db27102637 100644 --- a/extension/src/popup/ducks/settings.ts +++ b/extension/src/popup/ducks/settings.ts @@ -28,6 +28,10 @@ import { AssetsLists, DEFAULT_ASSETS_LISTS, } from "@shared/constants/soroban/asset-list"; +import { + AutoLockTimeoutMinutes, + DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, +} from "@shared/constants/autoLock"; import { AllowList, @@ -35,6 +39,7 @@ import { IndexerSettings, SettingsState, ExperimentalFeatures, + SaveSettingsResponse, } from "@shared/api/types"; import { publicKeySelector } from "popup/ducks/accountServices"; import { AppState } from "popup/App"; @@ -59,6 +64,8 @@ const settingsInitialState: Settings = { isMemoValidationEnabled: true, isHideDustEnabled: true, isOpenSidebarByDefault: false, + autoLockTimeoutMinutes: + DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES as AutoLockTimeoutMinutes, error: "", }; @@ -120,12 +127,13 @@ export const saveAllowList = createAsyncThunk< ); export const saveSettings = createAsyncThunk< - Settings & IndexerSettings, + SaveSettingsResponse, { isDataSharingAllowed: boolean; isMemoValidationEnabled: boolean; isHideDustEnabled: boolean; isOpenSidebarByDefault: boolean; + autoLockTimeoutMinutes: AutoLockTimeoutMinutes; }, { rejectValue: ErrorMessage; state: AppState } >( @@ -136,17 +144,22 @@ export const saveSettings = createAsyncThunk< isMemoValidationEnabled, isHideDustEnabled, isOpenSidebarByDefault, + autoLockTimeoutMinutes, }, { getState, rejectWithValue }, ) => { - let res = { - ...settingsInitialState, - isSorobanPublicEnabled: false, + let res: SaveSettingsResponse = { + allowList: DEFAULT_ALLOW_LIST, + isDataSharingAllowed: false, + isMemoValidationEnabled: false, + networkDetails: settingsInitialState.networkDetails, + networksList: settingsInitialState.networksList, isRpcHealthy: false, - userNotification: { enabled: false, message: "" }, - settingsState: SettingsState.IDLE, + isSorobanPublicEnabled: false, + isNonSSLEnabled: false, isHideDustEnabled: true, isOpenSidebarByDefault: false, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }; const activePublicKey = publicKeySelector(getState()); @@ -157,6 +170,7 @@ export const saveSettings = createAsyncThunk< isMemoValidationEnabled, isHideDustEnabled, isOpenSidebarByDefault, + autoLockTimeoutMinutes, }); } catch (e) { console.error(e); @@ -375,6 +389,7 @@ const settingsSlice = createSlice({ isNonSSLEnabled, isHideDustEnabled, isOpenSidebarByDefault, + autoLockTimeoutMinutes, } = payload; state.allowList = allowList; state.isDataSharingAllowed = isDataSharingAllowed; @@ -387,6 +402,8 @@ const settingsSlice = createSlice({ state.isNonSSLEnabled = isNonSSLEnabled; state.isHideDustEnabled = isHideDustEnabled; state.isOpenSidebarByDefault = isOpenSidebarByDefault; + state.autoLockTimeoutMinutes = + autoLockTimeoutMinutes ?? DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES; state.overriddenBlockaidResponse = payload.overriddenBlockaidResponse ?? null; state.settingsState = SettingsState.SUCCESS; @@ -436,11 +453,8 @@ const settingsSlice = createSlice({ isSorobanPublicEnabled, isHideDustEnabled, isOpenSidebarByDefault, - overriddenBlockaidResponse, - } = (action?.payload as typeof action.payload & { - overriddenBlockaidResponse?: string | null; - isOpenSidebarByDefault?: boolean; - }) || { + autoLockTimeoutMinutes, + } = action?.payload || { ...initialState, }; @@ -454,7 +468,8 @@ const settingsSlice = createSlice({ isSorobanPublicEnabled, isHideDustEnabled, isOpenSidebarByDefault: isOpenSidebarByDefault ?? false, - overriddenBlockaidResponse: overriddenBlockaidResponse ?? null, + autoLockTimeoutMinutes: + autoLockTimeoutMinutes ?? DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }; }); builder.addCase(saveExperimentalFeatures.pending, (state) => ({ @@ -700,3 +715,9 @@ export const isOpenSidebarByDefaultSelector = createSelector( settingsSelector, (settings) => settings.isOpenSidebarByDefault, ); + +export const autoLockTimeoutMinutesSelector = createSelector( + settingsSelector, + (settings): AutoLockTimeoutMinutes => + settings.autoLockTimeoutMinutes ?? DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, +); diff --git a/extension/src/popup/helpers/hooks/__tests__/useActivityPing.test.ts b/extension/src/popup/helpers/hooks/__tests__/useActivityPing.test.ts new file mode 100644 index 0000000000..6a6f5089e7 --- /dev/null +++ b/extension/src/popup/helpers/hooks/__tests__/useActivityPing.test.ts @@ -0,0 +1,107 @@ +import { renderHook } from "@testing-library/react"; + +import { sendMessageToBackground } from "@shared/api/helpers/extensionMessaging"; +import { SERVICE_TYPES } from "@shared/constants/services"; +import { useActivityPing } from "../useActivityPing"; + +jest.mock("@shared/api/helpers/extensionMessaging", () => ({ + sendMessageToBackground: jest.fn().mockResolvedValue({}), +})); + +const activityEvents = ["mousedown", "keydown", "touchstart", "wheel"]; + +describe("useActivityPing", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(10_000); + (sendMessageToBackground as jest.Mock).mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("sends USER_ACTIVITY on the first event and throttles events within 5 seconds", () => { + renderHook(() => useActivityPing(true)); + + window.dispatchEvent(new MouseEvent("mousedown")); + window.dispatchEvent(new KeyboardEvent("keydown")); + + expect(sendMessageToBackground).toHaveBeenCalledTimes(1); + expect(sendMessageToBackground).toHaveBeenCalledWith({ + type: SERVICE_TYPES.USER_ACTIVITY, + activePublicKey: "", + }); + }); + + it("sends another ping after the 5 second throttle window", () => { + renderHook(() => useActivityPing(true)); + + window.dispatchEvent(new MouseEvent("mousedown")); + jest.setSystemTime(14_999); + window.dispatchEvent(new WheelEvent("wheel")); + jest.setSystemTime(15_000); + window.dispatchEvent(new WheelEvent("wheel")); + + expect(sendMessageToBackground).toHaveBeenCalledTimes(2); + }); + + it("listens to all configured activity events", () => { + renderHook(() => useActivityPing(true)); + + for (const eventName of activityEvents) { + (sendMessageToBackground as jest.Mock).mockClear(); + jest.setSystemTime(Date.now() + 5_000); + window.dispatchEvent(new Event(eventName)); + expect(sendMessageToBackground).toHaveBeenCalledTimes(1); + } + }); + + it("cleans up listeners on unmount", () => { + const addSpy = jest.spyOn(window, "addEventListener"); + const removeSpy = jest.spyOn(window, "removeEventListener"); + + const { unmount } = renderHook(() => useActivityPing(true)); + unmount(); + + for (const eventName of activityEvents) { + expect(addSpy).toHaveBeenCalledWith(eventName, expect.any(Function), { + passive: true, + }); + expect(removeSpy).toHaveBeenCalledWith(eventName, expect.any(Function)); + } + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it("does not register listeners when locked", () => { + const addSpy = jest.spyOn(window, "addEventListener"); + + renderHook(() => useActivityPing(false)); + window.dispatchEvent(new MouseEvent("mousedown")); + + for (const eventName of activityEvents) { + expect(addSpy).not.toHaveBeenCalledWith( + eventName, + expect.any(Function), + expect.anything(), + ); + } + expect(sendMessageToBackground).not.toHaveBeenCalled(); + + addSpy.mockRestore(); + }); + + it("does not ping on mount (only user input resets the timer)", () => { + renderHook(() => useActivityPing(true)); + + expect(sendMessageToBackground).not.toHaveBeenCalled(); + }); + + it("does not ping on mount when locked", () => { + renderHook(() => useActivityPing(false)); + + expect(sendMessageToBackground).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/helpers/hooks/useActivityPing.ts b/extension/src/popup/helpers/hooks/useActivityPing.ts new file mode 100644 index 0000000000..ee872f17aa --- /dev/null +++ b/extension/src/popup/helpers/hooks/useActivityPing.ts @@ -0,0 +1,71 @@ +import { useEffect } from "react"; + +import { sendMessageToBackground } from "@shared/api/helpers/extensionMessaging"; +import { SERVICE_TYPES } from "@shared/constants/services"; + +const PING_THROTTLE_MS = 5_000; + +// We deliberately do NOT listen for `scroll`: Freighter's layouts use +// inner overflow containers (e.g. `popup/basics/layout/View`) where +// `scroll` does not bubble to `window`. `wheel` does bubble for mouse +// scrolling, and pointer/keyboard-driven scrolling already fires +// `mousedown` / `keydown`. +const ACTIVITY_EVENTS = [ + "mousedown", + "keydown", + "touchstart", + "wheel", +] as const; + +const sendPing = () => { + void sendMessageToBackground({ + type: SERVICE_TYPES.USER_ACTIVITY, + // `USER_ACTIVITY` is account-agnostic — the background only cares + // that *some* extension page is active, not which key is selected. + // Send an empty string (rather than null) to match `BaseMessage`'s + // `activePublicKey: string` typing; the background's mismatch + // check (`if (request.activePublicKey && …)`) treats empty-string + // as "skip the check", same as the previous null. + activePublicKey: "", + }); +}; + +/** + * Ping the background with USER_ACTIVITY messages whenever the user + * interacts with the extension page, while the wallet is unlocked. + * Uses a leading-edge throttle so a single ping fires on the first + * event in each `PING_THROTTLE_MS` window — accurate to ~8 % on the + * 1-minute preset and effectively noise at higher presets. + * + * Deliberately does NOT ping on mount. A surface mount is not by + * itself proof of user presence — dApp-spawned signing popups, for + * example, can be triggered programmatically while the user is away. + * Pinging on mount would let a malicious dApp prolong the session + * indefinitely by spamming sign requests. This matches MetaMask and + * Phantom: only direct user input inside the wallet UI resets the + * idle timer. + */ +export const useActivityPing = (isUnlocked: boolean) => { + // Event-driven pings: only while unlocked. No-op when locked so + // stray scrolls/clicks on the unlock screen don't generate traffic. + useEffect(() => { + if (!isUnlocked) return undefined; + + let lastPingAt = 0; + const handler = () => { + const now = Date.now(); + if (now - lastPingAt < PING_THROTTLE_MS) return; + lastPingAt = now; + sendPing(); + }; + + for (const evt of ACTIVITY_EVENTS) { + window.addEventListener(evt, handler, { passive: true }); + } + return () => { + for (const evt of ACTIVITY_EVENTS) { + window.removeEventListener(evt, handler); + } + }; + }, [isUnlocked]); +}; diff --git a/extension/src/popup/metrics/views.ts b/extension/src/popup/metrics/views.ts index c67923029a..c00a2d40aa 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -66,6 +66,7 @@ const routeToEventName = { [ROUTES.accountMigrationMigrationComplete]: METRIC_NAMES.viewAccountMigrationMigrationComplete, [ROUTES.advancedSettings]: METRIC_NAMES.viewAdvancedSettings, + [ROUTES.autoLockTimer]: METRIC_NAMES.viewAutoLockTimer, [ROUTES.addFunds]: METRIC_NAMES.viewAddFunds, [ROUTES.wallets]: METRIC_NAMES.wallets, [ROUTES.confirmSidebarRequest]: METRIC_NAMES.confirmSidebarRequest, diff --git a/extension/src/popup/views/AutoLockTimer/index.tsx b/extension/src/popup/views/AutoLockTimer/index.tsx new file mode 100644 index 0000000000..2d2af8a0b4 --- /dev/null +++ b/extension/src/popup/views/AutoLockTimer/index.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { Navigate, useLocation } from "react-router-dom"; +import { Icon, Notification } from "@stellar/design-system"; + +import { + AutoLockTimeoutMinutes, + VALID_AUTO_LOCK_TIMEOUT_MINUTES, + coerceAutoLockTimeoutMinutes, + formatTimeoutLabel, +} from "@shared/constants/autoLock"; +import { AppDispatch } from "popup/App"; +import { SubviewHeader } from "popup/components/SubviewHeader"; +import { View } from "popup/basics/layout/View"; +import { Loading } from "popup/components/Loading"; +import { AppDataType, useGetAppData } from "helpers/hooks/useGetAppData"; +import { RequestState } from "constants/request"; +import { openTab } from "popup/helpers/navigate"; +import { newTabHref } from "helpers/urls"; +import { reRouteOnboarding } from "popup/helpers/route"; +import { + autoLockTimeoutMinutesSelector, + saveSettings, + settingsSelector, +} from "popup/ducks/settings"; + +import "./styles.scss"; + +export const AutoLockTimer = () => { + const { t } = useTranslation(); + const location = useLocation(); + const dispatch = useDispatch(); + const { state, fetchData } = useGetAppData(); + const currentTimeout = useSelector(autoLockTimeoutMinutesSelector); + const settings = useSelector(settingsSelector); + + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + const getData = async () => { + await fetchData(); + }; + getData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if ( + state.state === RequestState.IDLE || + state.state === RequestState.LOADING + ) { + return ; + } + + if (state.state === RequestState.ERROR) { + return ( +
+ + {t("Your account data could not be fetched at this time.")} + +
+ ); + } + + if (state.data?.type === AppDataType.REROUTE) { + if (state.data.shouldOpenTab) { + openTab(newTabHref(state.data.routeTarget)); + window.close(); + } + return ( + + ); + } + + reRouteOnboarding({ + type: state.data.type, + applicationState: state.data.account.applicationState, + state: state.state, + }); + + const handleSelect = async (minutes: AutoLockTimeoutMinutes) => { + if (isSaving || minutes === currentTimeout) { + return; + } + setIsSaving(true); + await dispatch( + saveSettings({ + isDataSharingAllowed: + settings.isDataSharingAllowed ?? false, + isMemoValidationEnabled: + settings.isMemoValidationEnabled ?? true, + isHideDustEnabled: settings.isHideDustEnabled ?? true, + isOpenSidebarByDefault: settings.isOpenSidebarByDefault ?? false, + autoLockTimeoutMinutes: minutes, + }), + ); + setIsSaving(false); + }; + + return ( + + + +
+ {VALID_AUTO_LOCK_TIMEOUT_MINUTES.map((minutes) => { + const isSelected = + coerceAutoLockTimeoutMinutes(currentTimeout) === minutes; + return ( + + + + ); + })} +
+
+
+ ); +}; diff --git a/extension/src/popup/views/AutoLockTimer/styles.scss b/extension/src/popup/views/AutoLockTimer/styles.scss new file mode 100644 index 0000000000..0e82a88438 --- /dev/null +++ b/extension/src/popup/views/AutoLockTimer/styles.scss @@ -0,0 +1,53 @@ +@use "../../styles/utils.scss" as *; + +.AutoLockTimer { + display: flex; + flex-direction: column; + padding: pxToRem(16px); + border-radius: pxToRem(16px); + background-color: var(--sds-clr-gray-02); + + &__option { + align-items: center; + background: none; + border: none; + color: var(--sds-clr-gray-12); + cursor: pointer; + display: flex; + justify-content: space-between; + padding: pxToRem(16px) 0; + text-align: left; + width: 100%; + font-size: pxToRem(14px); + font-weight: 500; + line-height: pxToRem(20px); + + &:not(:last-child) { + border-bottom: 1px solid var(--sds-clr-gray-06); + } + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } + + &--disabled { + cursor: default; + opacity: 0.6; + } + + &__label { + flex: 1; + } + + &__check { + flex-shrink: 0; + height: pxToRem(20px); + width: pxToRem(20px); + color: var(--sds-clr-gray-12); + } + } +} diff --git a/extension/src/popup/views/IntegrationTest.tsx b/extension/src/popup/views/IntegrationTest.tsx index 9e1c1fa2ab..28e19a7f2f 100644 --- a/extension/src/popup/views/IntegrationTest.tsx +++ b/extension/src/popup/views/IntegrationTest.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect } from "react"; import { Networks } from "stellar-sdk"; import { useTranslation } from "react-i18next"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; + import { createAccount, changeNetwork, @@ -348,6 +350,7 @@ export const IntegrationTest = () => { isMemoValidationEnabled: true, isHideDustEnabled: true, isOpenSidebarByDefault: false, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }); runAsserts("saveSettings", () => { assertEq(res.networkDetails, FUTURENET_NETWORK_DETAILS); diff --git a/extension/src/popup/views/Preferences/index.tsx b/extension/src/popup/views/Preferences/index.tsx index 821e6e6479..ff86b24361 100644 --- a/extension/src/popup/views/Preferences/index.tsx +++ b/extension/src/popup/views/Preferences/index.tsx @@ -1,12 +1,15 @@ import React, { useEffect } from "react"; import { Notification, Toggle } from "@stellar/design-system"; import { Field, Form, Formik } from "formik"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import { View } from "popup/basics/layout/View"; import { AppDispatch } from "popup/App"; -import { saveSettings } from "popup/ducks/settings"; +import { + autoLockTimeoutMinutesSelector, + saveSettings, +} from "popup/ducks/settings"; import { SubviewHeader } from "popup/components/SubviewHeader"; import { AutoSaveFields } from "popup/components/AutoSave"; import { AppDataType, useGetAppData } from "helpers/hooks/useGetAppData"; @@ -24,6 +27,7 @@ export const Preferences = () => { const location = useLocation(); const dispatch = useDispatch(); const { state, fetchData } = useGetAppData(); + const autoLockTimeoutMinutes = useSelector(autoLockTimeoutMinutesSelector); interface SettingValues { isValidatingMemoValue: boolean; @@ -46,6 +50,7 @@ export const Preferences = () => { isDataSharingAllowed: isDataSharingAllowedValue, isHideDustEnabled: isHideDustEnabledValue, isOpenSidebarByDefault: isOpenSidebarByDefaultValue, + autoLockTimeoutMinutes, }), ); }; diff --git a/extension/src/popup/views/Security/index.tsx b/extension/src/popup/views/Security/index.tsx index 12b5f4f70a..ea572163b7 100644 --- a/extension/src/popup/views/Security/index.tsx +++ b/extension/src/popup/views/Security/index.tsx @@ -52,6 +52,12 @@ export const Security = () => { > {t("Advanced settings")} + } + > + {t("Auto-lock timer")} + {/* { openTab(newTabHref(ROUTES.accountMigration)); diff --git a/extension/src/popup/views/UnlockAccount/index.tsx b/extension/src/popup/views/UnlockAccount/index.tsx index 359c092ece..d0f4c71b7d 100644 --- a/extension/src/popup/views/UnlockAccount/index.tsx +++ b/extension/src/popup/views/UnlockAccount/index.tsx @@ -1,8 +1,8 @@ import { Button } from "@stellar/design-system"; import get from "lodash/get"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; import { newTabHref } from "helpers/urls"; @@ -12,6 +12,7 @@ import { openTab } from "popup/helpers/navigate"; import { View } from "popup/basics/layout/View"; import { confirmPassword, + hasPrivateKeySelector, loadLastUsedAccount, } from "popup/ducks/accountServices"; import { EnterPassword } from "popup/components/EnterPassword"; @@ -30,14 +31,27 @@ export const UnlockAccount = () => { const [accountAddress, setAccountAddress] = useState(""); const dispatch = useDispatch(); + const hasPrivateKey = useSelector(hasPrivateKeySelector); + // Track the initial auth state so we don't auto-navigate if the + // component happens to mount while the wallet is already unlocked + // (e.g. a stale `/unlock-account` route). We only redirect on the + // false → true transition that signals an unlock just happened + // (either via this surface's password submit or via a cross-surface + // SESSION_UNLOCKED broadcast saving fresh auth state). + const wasLockedOnMount = useRef(!hasPrivateKey); const handleSubmit = async (password: string) => { - const res = await dispatch(confirmPassword(password)); - if (confirmPassword.fulfilled.match(res) && res.payload.publicKey) { - // skip this location in history, we won't need to come back here after unlocking account + await dispatch(confirmPassword(password)); + // Navigation is handled by the `hasPrivateKey` effect below so + // that both password-submit and cross-surface SESSION_UNLOCKED + // broadcasts converge on the same destination. + }; + + useEffect(() => { + if (wasLockedOnMount.current && hasPrivateKey) { navigate(`${destination}${queryParams}`, { replace: true }); } - }; + }, [hasPrivateKey, destination, queryParams, navigate]); useEffect(() => { const fetchLastUsedAccount = async () => { diff --git a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx index 0c678ee3b9..eab97eb26d 100644 --- a/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx +++ b/extension/src/popup/views/Wallets/hooks/__tests__/useGetWalletsData.test.tsx @@ -66,6 +66,7 @@ describe("useGetWalletsData", () => { const preloadedState = { auth: { publicKey: TEST_PUBLIC_KEY, + hasPrivateKey: true, allAccounts: mockAccounts, applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, }, @@ -113,6 +114,7 @@ describe("useGetWalletsData", () => { const mainnetPreloadedState = { auth: { publicKey: "G1", + hasPrivateKey: true, allAccounts: [{ publicKey: "G1" }], applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, }, @@ -166,6 +168,7 @@ describe("useGetWalletsData", () => { const mainnetPreloadedState = { auth: { publicKey: "G1", + hasPrivateKey: true, allAccounts: [ { publicKey: "G1" }, { publicKey: "G2" }, diff --git a/extension/src/popup/views/__tests__/Account.test.tsx b/extension/src/popup/views/__tests__/Account.test.tsx index 404fe38544..a0c4fc57ad 100644 --- a/extension/src/popup/views/__tests__/Account.test.tsx +++ b/extension/src/popup/views/__tests__/Account.test.tsx @@ -41,6 +41,7 @@ import { import { Account } from "../Account"; import { ROUTES } from "popup/constants/routes"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; import { AppDataType } from "helpers/hooks/useGetAppData"; import * as AccountDataHooks from "../../views/Account/hooks/useGetAccountData"; import { RequestState } from "helpers/hooks/fetchHookInterface"; @@ -242,6 +243,7 @@ jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); @@ -888,6 +890,7 @@ describe("Account view", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); @@ -942,6 +945,7 @@ describe("Account view", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); jest @@ -1026,6 +1030,7 @@ describe("Account view", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); jest @@ -1109,6 +1114,7 @@ describe("Account view", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); jest @@ -1191,6 +1197,7 @@ describe("Account view", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); jest @@ -1256,6 +1263,7 @@ describe("Account view", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const getAccountBalancesSpy = jest diff --git a/extension/src/popup/views/__tests__/AccountCreator.test.tsx b/extension/src/popup/views/__tests__/AccountCreator.test.tsx index ab514e89cc..b7de1e6926 100644 --- a/extension/src/popup/views/__tests__/AccountCreator.test.tsx +++ b/extension/src/popup/views/__tests__/AccountCreator.test.tsx @@ -17,6 +17,7 @@ import { ROUTES } from "popup/constants/routes"; import * as ApiInternal from "@shared/api/internal"; import { SettingsState } from "@shared/api/types"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; jest.spyOn(ApiInternal, "loadAccount").mockImplementation(() => Promise.resolve({ @@ -52,6 +53,7 @@ jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); diff --git a/extension/src/popup/views/__tests__/AccountHistory.test.tsx b/extension/src/popup/views/__tests__/AccountHistory.test.tsx index 67898dba7b..7ca9e3b823 100644 --- a/extension/src/popup/views/__tests__/AccountHistory.test.tsx +++ b/extension/src/popup/views/__tests__/AccountHistory.test.tsx @@ -28,6 +28,7 @@ import { AccountHistory } from "../AccountHistory"; import { ROUTES } from "popup/constants/routes"; import { SettingsState } from "@shared/api/types"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; import * as ExtensionMessaging from "@shared/api/helpers/extensionMessaging"; import * as GetIconFromTokenList from "@shared/api/helpers/getIconFromTokenList"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; @@ -103,6 +104,7 @@ jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); diff --git a/extension/src/popup/views/__tests__/AddFunds.test.tsx b/extension/src/popup/views/__tests__/AddFunds.test.tsx index 5df25b640e..8ad606cacf 100644 --- a/extension/src/popup/views/__tests__/AddFunds.test.tsx +++ b/extension/src/popup/views/__tests__/AddFunds.test.tsx @@ -17,6 +17,7 @@ import { Wrapper, mockAccounts } from "../../__testHelpers__"; import { AddFunds } from "../AddFunds"; import { SettingsState } from "@shared/api/types"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; const token = "foo"; @@ -60,6 +61,7 @@ jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); @@ -84,6 +86,7 @@ describe("AddFunds view", () => { error: null, applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "G1", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { @@ -116,6 +119,7 @@ describe("AddFunds view", () => { error: null, applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "G1", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { diff --git a/extension/src/popup/views/__tests__/GrantAccess.test.tsx b/extension/src/popup/views/__tests__/GrantAccess.test.tsx index 8b9ca9873a..1c830d25b8 100644 --- a/extension/src/popup/views/__tests__/GrantAccess.test.tsx +++ b/extension/src/popup/views/__tests__/GrantAccess.test.tsx @@ -18,6 +18,7 @@ import * as urlHelpers from "../../../helpers/urls"; import { ROUTES } from "popup/constants/routes"; import * as ApiInternal from "@shared/api/internal"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; import { CUSTOM_NETWORK } from "@shared/helpers/stellar"; const mockLoadAccount = () => @@ -53,6 +54,7 @@ const mockLoadSettings = () => isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }); describe("Grant Access view", () => { @@ -99,6 +101,7 @@ describe("Grant Access view", () => { applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { @@ -141,6 +144,7 @@ describe("Grant Access view", () => { applicationState: ApplicationState.PASSWORD_CREATED, publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { @@ -176,6 +180,7 @@ describe("Grant Access view", () => { applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { @@ -216,6 +221,7 @@ describe("Grant Access view", () => { applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { @@ -251,6 +257,7 @@ describe("Grant Access view", () => { applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { @@ -292,6 +299,7 @@ describe("Grant Access view", () => { applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { @@ -347,6 +355,7 @@ describe("Grant Access view", () => { applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + hasPrivateKey: true, allAccounts: mockAccounts, }, settings: { diff --git a/extension/src/popup/views/__tests__/ManageAssets.test.tsx b/extension/src/popup/views/__tests__/ManageAssets.test.tsx index 3e7520f863..316f59be66 100644 --- a/extension/src/popup/views/__tests__/ManageAssets.test.tsx +++ b/extension/src/popup/views/__tests__/ManageAssets.test.tsx @@ -38,6 +38,7 @@ import { Wrapper, mockAccounts } from "../../__testHelpers__"; import { ManageAssets } from "../ManageAssets"; import { SettingsState } from "@shared/api/types"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; const mockXDR = "AAAAAgAAAADaBSz5rQFDZHNdV8//w/Yiy11vE1ZxGJ8QD8j7HUtNEwAAAGQAAAAAAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADaBSz5rQFDZHNdV8//w/Yiy11vE1ZxGJ8QD8j7HUtNEwAAAAAAAAAAAvrwgAAAAAAAAAABHUtNEwAAAEBY/jSiXJNsA2NpiXrOi6Ll6RiIY7v8QZEEZviM8HmmzeI4FBP9wGZm7YMorQue+DK9KI5BEXDt3hi0VOA9gD8A"; @@ -330,6 +331,7 @@ jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); diff --git a/extension/src/popup/views/__tests__/SignTransaction.test.tsx b/extension/src/popup/views/__tests__/SignTransaction.test.tsx index 4d7f4cc320..c2c20ee36c 100644 --- a/extension/src/popup/views/__tests__/SignTransaction.test.tsx +++ b/extension/src/popup/views/__tests__/SignTransaction.test.tsx @@ -20,6 +20,7 @@ import { } from "@shared/constants/stellar"; import { SettingsState } from "@shared/api/types"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; import * as UseIsDomainAllowed from "popup/helpers/useIsDomainListedAllowed"; import * as SignTxDataHooks from "../SignTransaction/hooks/useGetSignTxData"; import { RequestState } from "constants/request"; @@ -88,6 +89,7 @@ jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); @@ -337,6 +339,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); @@ -453,6 +456,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const transaction = TransactionBuilder.fromXDR( @@ -592,6 +596,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); @@ -706,6 +711,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const transaction = TransactionBuilder.fromXDR( @@ -828,6 +834,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const transaction = TransactionBuilder.fromXDR( @@ -950,6 +957,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const transaction = TransactionBuilder.fromXDR( @@ -1066,6 +1074,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const transaction = TransactionBuilder.fromXDR( @@ -1188,6 +1197,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); @@ -1327,6 +1337,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const transaction = TransactionBuilder.fromXDR( @@ -1501,6 +1512,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const transaction = TransactionBuilder.fromXDR( @@ -1667,6 +1679,7 @@ describe("SignTransactions", () => { isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), ); const transaction = TransactionBuilder.fromXDR( diff --git a/extension/src/popup/views/__tests__/Swap.test.tsx b/extension/src/popup/views/__tests__/Swap.test.tsx index 9389ae3257..26655a2526 100644 --- a/extension/src/popup/views/__tests__/Swap.test.tsx +++ b/extension/src/popup/views/__tests__/Swap.test.tsx @@ -28,6 +28,7 @@ import { Wrapper, mockAccounts } from "../../__testHelpers__"; import * as GetIconHelper from "@shared/api/helpers/getIconUrlFromIssuer"; import { SettingsState } from "@shared/api/types"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/asset-list"; +import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; export const swapMockBalances = { balances: { @@ -173,6 +174,7 @@ jest.spyOn(ApiInternal, "loadSettings").mockImplementation(() => isNonSSLEnabled: false, experimentalFeaturesState: SettingsState.SUCCESS, assetsLists: DEFAULT_ASSETS_LISTS, + autoLockTimeoutMinutes: DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, }), );