diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 85fd2b576d..30e53805ff 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -2407,3 +2407,19 @@ export const markQueueActive = async ({ console.error(e); } }; + +export const rejectSigningRequest = async ({ + uuid, +}: { + uuid: string; +}): Promise => { + try { + await sendMessageToBackground({ + activePublicKey: null, + uuid, + type: SERVICE_TYPES.REJECT_SIGNING_REQUEST, + }); + } catch (e) { + console.error(e); + } +}; diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 9eff6052ce..a1bd20f86d 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -445,20 +445,16 @@ export interface MarkQueueActiveMessage extends BaseMessage { isActive: boolean; } -export interface SidebarRegisterMessage extends BaseMessage { - type: SERVICE_TYPES.SIDEBAR_REGISTER; - windowId: number; -} - -export interface SidebarUnregisterMessage extends BaseMessage { - type: SERVICE_TYPES.SIDEBAR_UNREGISTER; -} - export interface OpenSidebarMessage extends BaseMessage { type: SERVICE_TYPES.OPEN_SIDEBAR; windowId: number; } +export interface RejectSigningRequestMessage extends BaseMessage { + type: SERVICE_TYPES.REJECT_SIGNING_REQUEST; + uuid: string; +} + export type ServiceMessageRequest = | FundAccountMessage | CreateAccountMessage @@ -524,6 +520,5 @@ export type ServiceMessageRequest = | ChangeCollectibleVisibilityMessage | GetHiddenCollectiblesMessage | MarkQueueActiveMessage - | SidebarRegisterMessage - | SidebarUnregisterMessage - | OpenSidebarMessage; + | OpenSidebarMessage + | RejectSigningRequestMessage; diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index ad9c9445b9..93f3032ab9 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -63,11 +63,12 @@ export enum SERVICE_TYPES { CHANGE_COLLECTIBLE_VISIBILITY = "CHANGE_COLLECTIBLE_VISIBILITY", GET_HIDDEN_COLLECTIBLES = "GET_HIDDEN_COLLECTIBLES", MARK_QUEUE_ACTIVE = "MARK_QUEUE_ACTIVE", - SIDEBAR_REGISTER = "SIDEBAR_REGISTER", - SIDEBAR_UNREGISTER = "SIDEBAR_UNREGISTER", OPEN_SIDEBAR = "OPEN_SIDEBAR", + REJECT_SIGNING_REQUEST = "REJECT_SIGNING_REQUEST", } +export const SIDEBAR_NAVIGATE = "SIDEBAR_NAVIGATE"; + export enum EXTERNAL_SERVICE_TYPES { REQUEST_ACCESS = "REQUEST_ACCESS", REQUEST_PUBLIC_KEY = "REQUEST_PUBLIC_KEY", diff --git a/extension/CLAUDE.md b/extension/CLAUDE.md new file mode 100644 index 0000000000..77b6dd9405 --- /dev/null +++ b/extension/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with the Freighter browser extension. + +## Specs + +Implementation specs for complex features live in `extension/specs/`. Check there for architectural context before deep codebase exploration. diff --git a/extension/public/static/manifest/v3.json b/extension/public/static/manifest/v3.json index 5a8a1124bc..59742b4d1c 100644 --- a/extension/public/static/manifest/v3.json +++ b/extension/public/static/manifest/v3.json @@ -38,5 +38,13 @@ "side_panel": { "default_path": "index.html?mode=sidebar" }, + "sidebar_action": { + "default_panel": "index.html?mode=sidebar", + "default_icon": { + "16": "images/icon16.png", + "32": "images/icon32.png" + }, + "open_at_install": false + }, "manifest_version": 3 } diff --git a/extension/specs/SIDEBAR_MODE.md b/extension/specs/SIDEBAR_MODE.md new file mode 100644 index 0000000000..4661a85a9f --- /dev/null +++ b/extension/specs/SIDEBAR_MODE.md @@ -0,0 +1,153 @@ +# Sidebar Mode Implementation Spec + +## Overview + +Freighter's sidebar mode allows the extension to render in the browser's side panel instead of a popup window. When active, signing requests navigate within the sidebar rather than opening separate popup windows. + +## How Sidebar Mode Is Detected + +**File:** `src/popup/helpers/isSidebarMode.ts` + +```ts +export const isSidebarMode = () => + /^(chrome|moz)-extension:$/.test(window.location.protocol) && + new URLSearchParams(window.location.search).get("mode") === "sidebar"; +``` + +The URL `index.html?mode=sidebar` is set in the manifest and passed by `openSidebar()`. + +## How Sidebar Mode Is Activated + +**UI entry point:** `src/popup/components/account/AccountHeader/index.tsx` (lines 185-200) + +The account options dropdown (test ID: `account-options-dropdown`) shows a "Sidebar mode" menu item, conditionally rendered only when the browser supports it (`chrome.sidePanel.open` or `browser.sidebarAction.open`). + +**Activation function:** `src/popup/helpers/navigate.ts` — `openSidebar()` + +- **Chrome:** Calls `chrome.sidePanel.setOptions({ path: "index.html?mode=sidebar" })` then `chrome.sidePanel.open({ windowId })`. +- **Firefox:** Calls `browser.sidebarAction.open()`. +- In both cases, `window.close()` is called afterward to close the popup. + +**Manifest config:** + +```json +"permissions": ["storage", "alarms", "sidePanel"], +"side_panel": { "default_path": "index.html?mode=sidebar" }, +"sidebar_action": { + "default_panel": "index.html?mode=sidebar", + "open_at_install": false +} +``` + +## Architecture: Signing Flow in Sidebar Mode + +### 1. Sidebar connects to background + +When `isSidebarMode()` returns true, `Router.tsx` mounts ``. + +**File:** `src/popup/components/SidebarSigningListener/index.tsx` + +On mount, the component: + +1. Opens a long-lived port to the background: `browser.runtime.connect({ name: "sidebar" })` +2. Sends the window ID to the background: `port.postMessage({ windowId: win.id })` +3. Overrides `window.close()` to navigate to the account route instead of closing the panel +4. Listens for `SIDEBAR_NAVIGATE` messages on the port + +### 2. Background registers the sidebar + +**File:** `src/background/index.ts` — `initSidebarConnectionListener()` (line 65) + +When the background receives a port connection named "sidebar": + +1. **Validates the sender** — rejects content scripts (`!port.sender?.tab` check ensures only extension pages connect) +2. Stores the port via `setSidebarPort(port)` (in `src/background/helpers/sidebarPort.ts`) +3. Stores the window ID via `setSidebarWindowId()` when the sidebar sends its first message +4. On disconnect, clears state and schedules a 500ms deferred cleanup (to allow quick sidebar reloads without dropping requests) + +### 3. Signing request routing + +**File:** `src/background/messageListener/freighterApiMessageListener.ts` — `openSigningWindow()` (line 76) + +When a dApp triggers a signing request: + +1. Checks `getSidebarWindowId()` — if not null, sidebar is active +2. **Sidebar path:** Sends `{ type: SIDEBAR_NAVIGATE, route: hashRoute }` over the port, then calls `chrome.sidePanel.open()` to focus it. Returns `null` (no popup created). +3. **Popup fallback:** If no sidebar, creates a standalone popup window via `browser.windows.create()` + +### 4. Sidebar handles navigation + +Back in `SidebarSigningListener`, the port handler receives the `SIDEBAR_NAVIGATE` message: + +1. **Route validation:** Only allows navigation to known signing routes (`signTransaction`, `signAuthEntry`, `signMessage`, `grantAccess`, `addToken`, `reviewAuthorization`) +2. **Concurrent request handling:** If the user is already on a signing route, navigates to `ConfirmSidebarRequest` interstitial instead of silently swapping the screen +3. **Otherwise:** Navigates directly to the signing route via React Router + +### 5. Concurrent request interstitial + +**File:** `src/popup/views/ConfirmSidebarRequest/index.tsx` + +Shows "New Signing Request" with two options: + +- **Reject:** Extracts the UUID from the pending route's query string, calls `rejectSigningRequest()` to clean up all queues, navigates to account +- **Continue to review:** Navigates to the new signing route (passed via `?next=` query param, validated against open redirect) + +## Queue Tracking + +**File:** `src/background/helpers/queueCleanup.ts` + +`sidebarQueueUuids: Set` tracks which signing requests were routed to the sidebar. UUIDs are added at routing time in `openSigningWindow()` (in `freighterApiMessageListener.ts`) when the sidebar path is chosen — not at view mount time. This ensures requests behind the `ConfirmSidebarRequest` interstitial are tracked before the signing view mounts. + +When the sidebar disconnects (and doesn't reconnect within 500ms), only these UUIDs are rejected — standalone popup requests have their own `onWindowRemoved` cleanup. The disconnect cleanup only fires when the disconnecting port is the currently active sidebar port; stale port disconnects are ignored. + +## Key Constants + +| Constant | Value | Location | +| ------------------------------ | ---------------------------- | ---------------------------------- | +| `SIDEBAR_PORT_NAME` | `"sidebar"` | `SidebarSigningListener/index.tsx` | +| `SIDEBAR_NAVIGATE` | `"SIDEBAR_NAVIGATE"` | `@shared/constants/services.ts` | +| `ROUTES.confirmSidebarRequest` | `"/confirm-sidebar-request"` | `popup/constants/routes.ts` | + +## "Open Sidebar Mode by Default" — Chrome Only + +The Preferences page (`src/popup/views/Preferences/index.tsx`, line 185) has a toggle labeled "Open sidebar mode by default" that makes the extension icon click open the sidebar instead of the popup. This toggle is **only shown on Chrome** — it's gated behind `typeof globalThis.chrome?.sidePanel?.open === "function"`. + +**Why it's hidden on Firefox:** + +The toggle works by calling `chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })` (in `src/background/index.ts` — `initSidebarBehavior()`, line 217, and `src/background/messageListener/handlers/saveSettings.ts`, line 44). This is a Chrome-only API that tells the browser to open the side panel instead of the popup when the user clicks the extension's toolbar icon. + +Firefox has no equivalent API. `browser.sidebarAction` can open/close the sidebar programmatically, but: + +1. `sidebarAction.open()` requires a synchronous user gesture (it cannot be called from a background script in response to an action click) +2. There is no `setPanelBehavior` equivalent to redirect action clicks to the sidebar + +The setting is persisted in `localStorage` under `IS_OPEN_SIDEBAR_BY_DEFAULT_ID` (`"isOpenSidebarByDefault"`). On Chrome, `initSidebarBehavior()` reads this on startup and applies it via `setPanelBehavior`. On Firefox, the function is a no-op — the comment at line 228 documents this explicitly. + +Firefox users can still open sidebar mode manually via the "Sidebar mode" menu item in the account dropdown, or via the browser's native sidebar toggle. + +## E2E Testing Limitations + +**Playwright cannot test true sidebar mode.** The background's `initSidebarConnectionListener` rejects port connections from pages that have `port.sender.tab` set. In Playwright, the extension page runs in a regular browser tab (not a real side panel), so the port is always rejected. This means: + +- `getSidebarWindowId()` is always null in Playwright +- `openSigningWindow()` always falls back to creating popup windows +- The `SidebarSigningListener` component mounts but its port connection is rejected + +To properly E2E test sidebar signing flow, Playwright would need to support Chrome's Side Panel API so the extension page runs without a tab context. + +**What was tried and why it failed:** We attempted to create sidebar-specific E2E tests by navigating to `?mode=sidebar` in Playwright. While `isSidebarMode()` returns true on the frontend, the background rejects the `SidebarSigningListener` port connection because the page runs in a tab (`port.sender.tab` is set). All signing requests fall back to popup windows, making the tests functionally identical to the existing popup tests. True sidebar E2E tests require either Playwright support for Chrome's Side Panel API, or removing the `!port.sender.tab` guard in the background (which would weaken security). + +## File Reference + +| File | Role | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `src/popup/helpers/isSidebarMode.ts` | Detects sidebar mode via URL param | +| `src/popup/helpers/navigate.ts` | `openSidebar()` — opens side panel | +| `src/popup/components/SidebarSigningListener/index.tsx` | Port connection, navigation listener, window.close override | +| `src/popup/views/ConfirmSidebarRequest/index.tsx` | Concurrent request interstitial | +| `src/popup/Router.tsx` | Conditionally mounts SidebarSigningListener, defines confirmSidebarRequest route | +| `src/popup/components/account/AccountHeader/index.tsx` | "Sidebar mode" dropdown menu item | +| `src/background/index.ts` | `initSidebarConnectionListener()` — port validation, state management, cleanup | +| `src/background/helpers/sidebarPort.ts` | Global sidebar port state | +| `src/background/helpers/queueCleanup.ts` | `sidebarQueueUuids` set | +| `src/background/messageListener/freighterApiMessageListener.ts` | `openSigningWindow()` — routes to sidebar or popup | diff --git a/extension/src/background/helpers/queueCleanup.ts b/extension/src/background/helpers/queueCleanup.ts index b9b56618a1..8c7fdac6b6 100644 --- a/extension/src/background/helpers/queueCleanup.ts +++ b/extension/src/background/helpers/queueCleanup.ts @@ -15,6 +15,11 @@ export const CLEANUP_INTERVAL_MS = 60 * 1000; // In MV3, this resets when the service worker restarts, which also resets the queues. export const activeQueueUuids: Set = new Set(); +// Set of UUIDs for signing requests that are being handled by the sidebar +// (as opposed to a standalone popup window). Only these should be rejected +// when the sidebar disconnects. +export const sidebarQueueUuids: Set = new Set(); + /** * Removes expired items from a queue based on their createdAt timestamp. * Items older than the TTL are removed. Items without createdAt are also removed diff --git a/extension/src/background/helpers/sidebarPort.ts b/extension/src/background/helpers/sidebarPort.ts new file mode 100644 index 0000000000..9ee21f3767 --- /dev/null +++ b/extension/src/background/helpers/sidebarPort.ts @@ -0,0 +1,12 @@ +import browser from "webextension-polyfill"; + +// Long-lived port to the sidebar, set by initSidebarConnectionListener +let sidebarPort: browser.Runtime.Port | null = null; + +export const setSidebarPort = (port: browser.Runtime.Port) => { + sidebarPort = port; +}; +export const clearSidebarPort = () => { + sidebarPort = null; +}; +export const getSidebarPort = () => sidebarPort; diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index 19eb0a9425..32278e084f 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -11,9 +11,20 @@ import { popupMessageListener, clearSidebarWindowId, setSidebarWindowId, + responseQueue, + transactionQueue, + blobQueue, + authEntryQueue, + tokenQueue, } from "./messageListener/popupMessageListener"; +import { + setSidebarPort, + clearSidebarPort, + getSidebarPort, +} from "./helpers/sidebarPort"; import { freighterApiMessageListener } from "./messageListener/freighterApiMessageListener"; import { SIDEBAR_PORT_NAME } from "popup/components/SidebarSigningListener"; +import { sidebarQueueUuids } from "./helpers/queueCleanup"; import { SESSION_ALARM_NAME, SessionTimer, @@ -52,19 +63,94 @@ export const initContentScriptMessageListener = () => { }; export const initSidebarConnectionListener = () => { - chrome.runtime.onConnect.addListener((port) => { + // Pending cleanup scheduled when a sidebar port disconnects. + // Cancelled if a new port connects before it fires (sidebar reloaded + // rather than closed, e.g. from chrome.sidePanel.open()). + let pendingCleanup: ReturnType | null = null; + + browser.runtime.onConnect.addListener((port) => { if (port.name !== SIDEBAR_PORT_NAME) return; + // Reject connections from content scripts; only extension pages are trusted. + // Extension pages have no associated tab and load from the extension origin. + const isExtensionPage = + !port.sender?.tab && + port.sender?.id === browser.runtime.id && + port.sender?.url?.startsWith(browser.runtime.getURL("")); + if (!isExtensionPage) { + port.disconnect(); + return; + } + + // A new sidebar connected — cancel any pending cleanup from a + // previous port disconnect so in-flight requests stay alive. + if (pendingCleanup !== null) { + clearTimeout(pendingCleanup); + pendingCleanup = null; + } + + // Store port reference so openSigningWindow can send messages directly + setSidebarPort(port); + // Sidebar sends its window ID as first message - port.onMessage.addListener((msg: { windowId: number }) => { - if (msg.windowId !== undefined) { - setSidebarWindowId(msg.windowId); + port.onMessage.addListener((msg: unknown) => { + if ( + typeof msg === "object" && + msg !== null && + "windowId" in msg && + typeof (msg as { windowId?: unknown }).windowId === "number" + ) { + setSidebarWindowId((msg as { windowId: number }).windowId); } }); - // When sidebar closes (for any reason), clear the window ID + // When sidebar closes, clear port state and schedule cleanup. + // The cleanup is deferred because chrome.sidePanel.open() can + // reload the sidebar page, causing a brief disconnect/reconnect. + // Only schedule cleanup when the disconnecting port is the currently + // active sidebar port — a stale port disconnecting should not trigger + // cleanup while a newer port is still connected. port.onDisconnect.addListener(() => { + if (getSidebarPort() !== port) { + return; + } + + clearSidebarPort(); clearSidebarWindowId(); + + pendingCleanup = setTimeout(() => { + pendingCleanup = null; + + // Reject only the signing requests that were routed to the sidebar. + // Requests handled by standalone popup windows have their own + // onWindowRemoved listeners and must not be cancelled here. + for (const uuid of sidebarQueueUuids) { + const responseIndex = responseQueue.findIndex( + (item) => item.uuid === uuid, + ); + if (responseIndex !== -1) { + const responseQueueItem = responseQueue.splice(responseIndex, 1)[0]; + responseQueueItem.response(undefined); + } + + const txIndex = transactionQueue.findIndex( + (item) => item.uuid === uuid, + ); + if (txIndex !== -1) transactionQueue.splice(txIndex, 1); + + const blobIndex = blobQueue.findIndex((item) => item.uuid === uuid); + if (blobIndex !== -1) blobQueue.splice(blobIndex, 1); + + const authIndex = authEntryQueue.findIndex( + (item) => item.uuid === uuid, + ); + if (authIndex !== -1) authEntryQueue.splice(authIndex, 1); + + const tokenIndex = tokenQueue.findIndex((item) => item.uuid === uuid); + if (tokenIndex !== -1) tokenQueue.splice(tokenIndex, 1); + } + sidebarQueueUuids.clear(); + }, 500); }); }); }; @@ -92,6 +178,7 @@ export const initExtensionMessageListener = () => { localStore, keyManager, sessionTimer, + sender, ); } if ( @@ -139,10 +226,14 @@ export const initSidebarBehavior = async () => { ((await localStore.getItem(IS_OPEN_SIDEBAR_BY_DEFAULT_ID)) as boolean) ?? false; if (chrome.sidePanel?.setPanelBehavior) { + // Chrome: delegate action-click to the side panel when enabled chrome.sidePanel - .setPanelBehavior({ openPanelOnActionClick: !!val }) + .setPanelBehavior({ openPanelOnActionClick: val }) .catch((e) => console.error("Failed to set panel behavior:", e)); } + // Firefox does not support "open sidebar by default" — sidebarAction.open() + // requires a synchronous user gesture and there is no setPanelBehavior equivalent. + // Users can still open the sidebar manually via the AccountHeader menu. }; export const initAlarmListener = () => { diff --git a/extension/src/background/messageListener/__tests__/createAccount.test.ts b/extension/src/background/messageListener/__tests__/createAccount.test.ts index 2ac13131ef..44a6dd24bd 100644 --- a/extension/src/background/messageListener/__tests__/createAccount.test.ts +++ b/extension/src/background/messageListener/__tests__/createAccount.test.ts @@ -48,6 +48,7 @@ describe("Create account message listener", () => { mockDataStorage, mockKeyManager, testAlarm, + { id: "fake-extension-id" }, )) as Awaited>; const { session } = mockSessionStore.getState(); @@ -74,6 +75,7 @@ describe("Create account message listener", () => { mockDataStorage, mockKeyManager, testAlarm, + { id: "fake-extension-id" }, )) as Awaited>; const keyId = await mockDataStorage.getItem(KEY_ID); @@ -87,6 +89,7 @@ describe("Create account message listener", () => { mockDataStorage, mockKeyManager, testAlarm, + { id: "fake-extension-id" }, )) as Awaited>; const secondKeyId = await mockDataStorage.getItem(KEY_ID); const keyIdList = await mockDataStorage.getItem(KEY_ID_LIST); @@ -109,6 +112,7 @@ describe("Create account message listener", () => { mockDataStorage, mockKeyManager, testAlarm, + { id: "fake-extension-id" }, )) as Awaited>; const keyId = await mockDataStorage.getItem(KEY_ID); @@ -122,6 +126,7 @@ describe("Create account message listener", () => { mockDataStorage, mockKeyManager, testAlarm, + { id: "fake-extension-id" }, )) as Awaited>; const secondKeyId = await mockDataStorage.getItem(KEY_ID); const keyIdList = await mockDataStorage.getItem(KEY_ID_LIST); diff --git a/extension/src/background/messageListener/__tests__/loadSaveSettings.test.ts b/extension/src/background/messageListener/__tests__/loadSaveSettings.test.ts new file mode 100644 index 0000000000..9f452cbc6c --- /dev/null +++ b/extension/src/background/messageListener/__tests__/loadSaveSettings.test.ts @@ -0,0 +1,120 @@ +import { loadSettings } from "../handlers/loadSettings"; +import { saveSettings } from "../handlers/saveSettings"; +import { IS_OPEN_SIDEBAR_BY_DEFAULT_ID } from "constants/localStorageTypes"; + +jest.mock("background/helpers/account", () => ({ + getAllowList: jest.fn().mockResolvedValue([]), + getAssetsLists: jest.fn().mockResolvedValue([]), + getIsExperimentalModeEnabled: jest.fn().mockResolvedValue(false), + getIsHashSigningEnabled: jest.fn().mockResolvedValue(false), + getIsHideDustEnabled: jest.fn().mockResolvedValue(true), + getIsMemoValidationEnabled: jest.fn().mockResolvedValue(true), + getIsNonSSLEnabled: jest.fn().mockResolvedValue(false), + getNetworkDetails: jest.fn().mockResolvedValue({ + network: "TESTNET", + networkName: "Test Net", + networkUrl: "https://horizon-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + }), + getNetworksList: jest.fn().mockResolvedValue([]), + verifySorobanRpcUrls: jest.fn().mockResolvedValue(undefined), + getFeatureFlags: jest.fn().mockResolvedValue({ useSorobanPublic: false }), + getOverriddenBlockaidResponse: jest.fn().mockResolvedValue(null), +})); + +jest.mock("../helpers/get-hidden-assets", () => ({ + getHiddenAssets: jest.fn().mockResolvedValue({ hiddenAssets: {} }), +})); + +(global as any).chrome = { + sidePanel: { + setPanelBehavior: jest.fn().mockResolvedValue(undefined), + }, +}; + +describe("loadSettings isOpenSidebarByDefault", () => { + it("returns true when storage value is boolean true", async () => { + const localStore = { + getItem: jest.fn().mockImplementation((key: string) => { + if (key === IS_OPEN_SIDEBAR_BY_DEFAULT_ID) return Promise.resolve(true); + return Promise.resolve(null); + }), + setItem: jest.fn(), + } as any; + + const result = await loadSettings({ localStore }); + expect(result.isOpenSidebarByDefault).toBe(true); + }); + + it("returns false when storage value is boolean false", async () => { + const localStore = { + getItem: jest.fn().mockImplementation((key: string) => { + if (key === IS_OPEN_SIDEBAR_BY_DEFAULT_ID) + return Promise.resolve(false); + return Promise.resolve(null); + }), + setItem: jest.fn(), + } as any; + + const result = await loadSettings({ localStore }); + expect(result.isOpenSidebarByDefault).toBe(false); + }); + + it("returns false when storage value is null", async () => { + const localStore = { + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn(), + } as any; + + const result = await loadSettings({ localStore }); + expect(result.isOpenSidebarByDefault).toBe(false); + }); +}); + +describe("saveSettings isOpenSidebarByDefault", () => { + 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); + return Promise.resolve(null); + }), + setItem: jest.fn().mockResolvedValue(undefined), + } as any; + + const request = { + isDataSharingAllowed: true, + isMemoValidationEnabled: true, + isHideDustEnabled: true, + isOpenSidebarByDefault: true, + } as any; + + const result = await saveSettings({ request, localStore }); + + expect(chrome.sidePanel.setPanelBehavior).toHaveBeenCalledWith({ + openPanelOnActionClick: true, + }); + expect(result.isOpenSidebarByDefault).toBe(true); + }); + + it("returns boolean false after saving false", async () => { + const localStore = { + getItem: jest.fn().mockImplementation((key: string) => { + if (key === IS_OPEN_SIDEBAR_BY_DEFAULT_ID) + return Promise.resolve(false); + return Promise.resolve(null); + }), + setItem: jest.fn().mockResolvedValue(undefined), + } as any; + + const request = { + isDataSharingAllowed: true, + isMemoValidationEnabled: true, + isHideDustEnabled: true, + isOpenSidebarByDefault: false, + } as any; + + const result = await saveSettings({ request, localStore }); + expect(result.isOpenSidebarByDefault).toBe(false); + expect(typeof result.isOpenSidebarByDefault).toBe("boolean"); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/sidebar.test.ts b/extension/src/background/messageListener/__tests__/sidebar.test.ts new file mode 100644 index 0000000000..815939d7db --- /dev/null +++ b/extension/src/background/messageListener/__tests__/sidebar.test.ts @@ -0,0 +1,172 @@ +import { SERVICE_TYPES } from "@shared/constants/services"; +import { popupMessageListener } from "background/messageListener/popupMessageListener"; +import { + setSidebarPort, + clearSidebarPort, + getSidebarPort, +} from "background/helpers/sidebarPort"; + +// Mock chrome.sidePanel API +const mockSetOptions = jest.fn().mockResolvedValue(undefined); +const mockOpen = jest.fn().mockResolvedValue(undefined); + +(global as any).chrome = { + sidePanel: { + setOptions: mockSetOptions, + open: mockOpen, + }, + runtime: { getURL: (path: string) => `chrome-extension://fake-id${path}` }, +}; + +const mockSessionStore = { + getState: jest.fn().mockReturnValue({ session: { publicKey: "" } }), +} as any; + +const mockLocalStore = { + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn(), +} as any; + +const mockKeyManager = {} as any; +const mockSessionTimer = {} as any; + +describe("sidebar message handlers", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("OPEN_SIDEBAR", () => { + const request = { + type: SERVICE_TYPES.OPEN_SIDEBAR, + windowId: 42, + }; + + it("returns Unauthorized when sender is a content script (has sender.tab)", async () => { + const contentScriptSender = { tab: { id: 1 } }; + const result = await popupMessageListener( + request as any, + mockSessionStore, + mockLocalStore, + mockKeyManager, + mockSessionTimer, + contentScriptSender, + ); + expect(result).toEqual({ error: "Unauthorized" }); + expect(mockSetOptions).not.toHaveBeenCalled(); + expect(mockOpen).not.toHaveBeenCalled(); + }); + + it("opens the sidebar when sender is an extension page (no sender.tab)", async () => { + const extensionPageSender = {}; + const result = await popupMessageListener( + request as any, + mockSessionStore, + mockLocalStore, + mockKeyManager, + mockSessionTimer, + extensionPageSender, + ); + expect(result).toEqual({}); + expect(mockSetOptions).toHaveBeenCalledWith({ + path: "index.html?mode=sidebar", + enabled: true, + }); + expect(mockOpen).toHaveBeenCalledWith({ windowId: 42 }); + }); + + it("opens the sidebar when sender is from this extension", async () => { + const result = await popupMessageListener( + request as any, + mockSessionStore, + mockLocalStore, + mockKeyManager, + mockSessionTimer, + {}, + ); + expect(result).toEqual({}); + expect(mockSetOptions).toHaveBeenCalled(); + expect(mockOpen).toHaveBeenCalled(); + }); + }); + + describe("sidebarWindowId management", () => { + it("getSidebarWindowId returns null initially", async () => { + const { + getSidebarWindowId, + clearSidebarWindowId, + } = require("background/messageListener/popupMessageListener"); + clearSidebarWindowId(); + expect(getSidebarWindowId()).toBeNull(); + }); + + it("setSidebarWindowId / clearSidebarWindowId work correctly", () => { + const { + getSidebarWindowId, + clearSidebarWindowId, + setSidebarWindowId, + } = require("background/messageListener/popupMessageListener"); + setSidebarWindowId(99); + expect(getSidebarWindowId()).toBe(99); + clearSidebarWindowId(); + expect(getSidebarWindowId()).toBeNull(); + }); + }); + + describe("sidebarPort management", () => { + afterEach(() => { + clearSidebarPort(); + }); + + it("setSidebarPort stores the port without throwing", () => { + const mockPort = { + postMessage: jest.fn(), + disconnect: jest.fn(), + } as any; + expect(() => setSidebarPort(mockPort)).not.toThrow(); + }); + + it("clearSidebarPort clears without throwing", () => { + const mockPort = { + postMessage: jest.fn(), + disconnect: jest.fn(), + } as any; + setSidebarPort(mockPort); + expect(() => clearSidebarPort()).not.toThrow(); + }); + + it("clearSidebarPort is safe to call when no port is set", () => { + expect(() => clearSidebarPort()).not.toThrow(); + }); + + it("getSidebarPort returns null after clearSidebarPort", () => { + const mockPort = { postMessage: jest.fn(), disconnect: jest.fn() } as any; + setSidebarPort(mockPort); + clearSidebarPort(); + expect(getSidebarPort()).toBeNull(); + }); + + it("getSidebarPort returns the currently stored port", () => { + const mockPort = { postMessage: jest.fn(), disconnect: jest.fn() } as any; + setSidebarPort(mockPort); + expect(getSidebarPort()).toBe(mockPort); + }); + + it("an older port disconnecting does not evict a newer sidebar port", () => { + const portA = { postMessage: jest.fn(), disconnect: jest.fn() } as any; + const portB = { postMessage: jest.fn(), disconnect: jest.fn() } as any; + + // Connect portA then portB (simulates a second sidebar window opening) + setSidebarPort(portA); + setSidebarPort(portB); + + // Simulate portA's disconnect handler: only clear if portA is still the active port + const disconnectingPort = portA; + if (getSidebarPort() === disconnectingPort) { + clearSidebarPort(); + } + + // portA disconnected but portB is still active – must not be cleared + expect(getSidebarPort()).toBe(portB); + }); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/sidebarDisconnect.test.ts b/extension/src/background/messageListener/__tests__/sidebarDisconnect.test.ts new file mode 100644 index 0000000000..445c979fab --- /dev/null +++ b/extension/src/background/messageListener/__tests__/sidebarDisconnect.test.ts @@ -0,0 +1,433 @@ +import browser from "webextension-polyfill"; +import { + setSidebarPort, + clearSidebarPort, + getSidebarPort, +} from "background/helpers/sidebarPort"; +import { + clearSidebarWindowId, + responseQueue, + transactionQueue, + blobQueue, + authEntryQueue, + tokenQueue, +} from "background/messageListener/popupMessageListener"; +import { + sidebarQueueUuids, + activeQueueUuids, +} from "background/helpers/queueCleanup"; +import { initSidebarConnectionListener } from "background/index"; +import { SIDEBAR_PORT_NAME } from "popup/components/SidebarSigningListener"; + +// Mock browser API +jest.mock("webextension-polyfill", () => ({ + runtime: { + onConnect: { + addListener: jest.fn(), + }, + getURL: (path: string) => `chrome-extension://fake-id${path}`, + }, + windows: { + onRemoved: { + addListener: jest.fn(), + removeListener: jest.fn(), + }, + }, +})); + +// Suppress buildStore and other background imports +jest.mock("background/store", () => ({ + buildStore: jest.fn(), +})); + +function createMockPort( + name: string, + sender?: { tab?: { id: number }; url?: string }, +): browser.Runtime.Port { + const messageListeners: Array<(msg: unknown) => void> = []; + const disconnectListeners: Array<() => void> = []; + + return { + name, + sender, + postMessage: jest.fn(), + disconnect: jest.fn(), + onMessage: { + addListener: (fn: (msg: unknown) => void) => messageListeners.push(fn), + removeListener: jest.fn(), + hasListener: jest.fn(), + hasListeners: jest.fn(), + }, + onDisconnect: { + addListener: (fn: () => void) => disconnectListeners.push(fn), + removeListener: jest.fn(), + hasListener: jest.fn(), + hasListeners: jest.fn(), + }, + // Helpers for tests to trigger events + _fireMessage: (msg: unknown) => messageListeners.forEach((fn) => fn(msg)), + _fireDisconnect: () => disconnectListeners.forEach((fn) => fn()), + } as unknown as browser.Runtime.Port & { + _fireMessage: (msg: unknown) => void; + _fireDisconnect: () => void; + }; +} + +type MockPort = ReturnType; + +describe("initSidebarConnectionListener", () => { + let onConnectCallback: (port: browser.Runtime.Port) => void; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + + // Clear global state + clearSidebarPort(); + clearSidebarWindowId(); + sidebarQueueUuids.clear(); + activeQueueUuids.clear(); + responseQueue.length = 0; + transactionQueue.length = 0; + blobQueue.length = 0; + authEntryQueue.length = 0; + tokenQueue.length = 0; + + // Register the listener + initSidebarConnectionListener(); + onConnectCallback = (browser.runtime.onConnect.addListener as jest.Mock) + .mock.calls[0][0]; + }); + + afterEach(() => { + jest.useRealTimers(); + clearSidebarPort(); + clearSidebarWindowId(); + sidebarQueueUuids.clear(); + activeQueueUuids.clear(); + }); + + function connectSidebarPort(sender?: { + tab?: { id: number }; + url?: string; + }): MockPort { + const port = createMockPort( + SIDEBAR_PORT_NAME, + sender ?? { + url: "chrome-extension://fake-id/index.html", + }, + ); + onConnectCallback(port); + return port as unknown as MockPort; + } + + describe("port connection", () => { + it("ignores ports with non-sidebar names", () => { + const port = createMockPort("other-port"); + onConnectCallback(port); + expect(getSidebarPort()).toBeNull(); + }); + + it("rejects connections from content scripts (has sender.tab)", () => { + const port = createMockPort(SIDEBAR_PORT_NAME, { + tab: { id: 1 }, + url: "https://evil.com", + }); + onConnectCallback(port); + expect(port.disconnect).toHaveBeenCalled(); + expect(getSidebarPort()).toBeNull(); + }); + + it("accepts connections from extension pages", () => { + const port = connectSidebarPort(); + expect(getSidebarPort()).toBe(port); + }); + + it("sets sidebarWindowId from the first message", () => { + const { + getSidebarWindowId, + } = require("background/messageListener/popupMessageListener"); + const port = connectSidebarPort(); + (port as any)._fireMessage({ windowId: 42 }); + expect(getSidebarWindowId()).toBe(42); + }); + + it("ignores malformed windowId messages", () => { + const { + getSidebarWindowId, + } = require("background/messageListener/popupMessageListener"); + clearSidebarWindowId(); + const port = connectSidebarPort(); + (port as any)._fireMessage({ windowId: "not-a-number" }); + expect(getSidebarWindowId()).toBeNull(); + (port as any)._fireMessage({ foo: "bar" }); + expect(getSidebarWindowId()).toBeNull(); + }); + }); + + describe("disconnect cleanup", () => { + it("clears port and windowId when active port disconnects", () => { + const { + getSidebarWindowId, + } = require("background/messageListener/popupMessageListener"); + const port = connectSidebarPort(); + (port as any)._fireMessage({ windowId: 42 }); + + (port as any)._fireDisconnect(); + + expect(getSidebarPort()).toBeNull(); + expect(getSidebarWindowId()).toBeNull(); + }); + + it("rejects sidebar requests after the deferred cleanup fires", () => { + const port = connectSidebarPort(); + const mockResponse = jest.fn(); + + sidebarQueueUuids.add("uuid-1"); + responseQueue.push({ + response: mockResponse, + uuid: "uuid-1", + createdAt: Date.now(), + } as any); + transactionQueue.push({ + transaction: {} as any, + uuid: "uuid-1", + createdAt: Date.now(), + }); + + (port as any)._fireDisconnect(); + + // Before timeout fires, request should still be in queue + expect(mockResponse).not.toHaveBeenCalled(); + expect(responseQueue).toHaveLength(1); + + // After timeout fires, request should be rejected + jest.advanceTimersByTime(500); + + expect(mockResponse).toHaveBeenCalledWith(undefined); + expect(responseQueue).toHaveLength(0); + expect(transactionQueue).toHaveLength(0); + expect(sidebarQueueUuids.size).toBe(0); + }); + + it("cleans up all queue types on disconnect", () => { + const port = connectSidebarPort(); + const uuid = "uuid-all-queues"; + + sidebarQueueUuids.add(uuid); + responseQueue.push({ + response: jest.fn(), + uuid, + createdAt: Date.now(), + } as any); + transactionQueue.push({ + transaction: {} as any, + uuid, + createdAt: Date.now(), + }); + blobQueue.push({ blob: {} as any, uuid, createdAt: Date.now() }); + authEntryQueue.push({ + authEntry: {} as any, + uuid, + createdAt: Date.now(), + }); + tokenQueue.push({ token: {} as any, uuid, createdAt: Date.now() }); + + (port as any)._fireDisconnect(); + jest.advanceTimersByTime(500); + + expect(responseQueue).toHaveLength(0); + expect(transactionQueue).toHaveLength(0); + expect(blobQueue).toHaveLength(0); + expect(authEntryQueue).toHaveLength(0); + expect(tokenQueue).toHaveLength(0); + }); + + it("does not reject popup-originated requests (not in sidebarQueueUuids)", () => { + const port = connectSidebarPort(); + const mockResponse = jest.fn(); + + // UUID is in responseQueue but NOT in sidebarQueueUuids + activeQueueUuids.add("popup-uuid"); + responseQueue.push({ + response: mockResponse, + uuid: "popup-uuid", + createdAt: Date.now(), + } as any); + + (port as any)._fireDisconnect(); + jest.advanceTimersByTime(500); + + expect(mockResponse).not.toHaveBeenCalled(); + expect(responseQueue).toHaveLength(1); + }); + }); + + describe("reconnect cancels pending cleanup", () => { + it("cancels rejection when a new port connects before timeout", () => { + const portA = connectSidebarPort(); + const mockResponse = jest.fn(); + + sidebarQueueUuids.add("uuid-1"); + responseQueue.push({ + response: mockResponse, + uuid: "uuid-1", + createdAt: Date.now(), + } as any); + + // Disconnect old port (schedules cleanup) + (portA as any)._fireDisconnect(); + + // New port connects before timeout fires (sidebar reloaded) + const portB = connectSidebarPort(); + + // Advance past the cleanup timeout + jest.advanceTimersByTime(500); + + // Request should NOT have been rejected + expect(mockResponse).not.toHaveBeenCalled(); + expect(responseQueue).toHaveLength(1); + expect(sidebarQueueUuids.has("uuid-1")).toBe(true); + expect(getSidebarPort()).toBe(portB); + }); + + it("rejects after reconnect if the second port also disconnects", () => { + const portA = connectSidebarPort(); + const mockResponse = jest.fn(); + + sidebarQueueUuids.add("uuid-1"); + responseQueue.push({ + response: mockResponse, + uuid: "uuid-1", + createdAt: Date.now(), + } as any); + + // Disconnect portA + (portA as any)._fireDisconnect(); + + // Reconnect portB (cancels portA's cleanup) + const portB = connectSidebarPort(); + + // Advance past portA's cleanup — should be cancelled + jest.advanceTimersByTime(500); + expect(mockResponse).not.toHaveBeenCalled(); + + // Now portB disconnects too (sidebar actually closed) + (portB as any)._fireDisconnect(); + jest.advanceTimersByTime(500); + + expect(mockResponse).toHaveBeenCalledWith(undefined); + expect(responseQueue).toHaveLength(0); + expect(sidebarQueueUuids.size).toBe(0); + }); + + it("stale port disconnect does not clear a newer port's state or schedule cleanup", () => { + const portA = connectSidebarPort(); + setSidebarPort(portA as any); + + // New port connects (simulates sidebar reload) + const portB = connectSidebarPort(); + + const mockResponse = jest.fn(); + sidebarQueueUuids.add("uuid-1"); + responseQueue.push({ + response: mockResponse, + uuid: "uuid-1", + createdAt: Date.now(), + } as any); + + // portA's disconnect fires late — should be a no-op + (portA as any)._fireDisconnect(); + + // portB should still be the active port + expect(getSidebarPort()).toBe(portB); + + // Advance past cleanup timeout — nothing should be rejected + // because stale port disconnect doesn't schedule cleanup + jest.advanceTimersByTime(500); + expect(mockResponse).not.toHaveBeenCalled(); + expect(responseQueue).toHaveLength(1); + expect(sidebarQueueUuids.has("uuid-1")).toBe(true); + }); + }); +}); + +describe("MARK_QUEUE_ACTIVE with sidebarQueueUuids", () => { + const { + popupMessageListener, + } = require("background/messageListener/popupMessageListener"); + + const mockSessionStore = { + getState: jest.fn().mockReturnValue({ session: { publicKey: "" } }), + } as any; + const mockLocalStore = { + getItem: jest.fn().mockResolvedValue(null), + setItem: jest.fn(), + } as any; + const mockKeyManager = {} as any; + const mockSessionTimer = {} as any; + + beforeEach(() => { + clearSidebarPort(); + sidebarQueueUuids.clear(); + activeQueueUuids.clear(); + }); + + afterEach(() => { + clearSidebarPort(); + sidebarQueueUuids.clear(); + activeQueueUuids.clear(); + }); + + it("adds to activeQueueUuids only — sidebarQueueUuids is populated at routing time in openSigningWindow", async () => { + const mockPort = { postMessage: jest.fn(), disconnect: jest.fn() } as any; + setSidebarPort(mockPort); + + await popupMessageListener( + { type: "MARK_QUEUE_ACTIVE", uuid: "test-uuid", isActive: true } as any, + mockSessionStore, + mockLocalStore, + mockKeyManager, + mockSessionTimer, + { id: "fake-extension-id" }, + ); + + expect(activeQueueUuids.has("test-uuid")).toBe(true); + // sidebarQueueUuids is no longer set here; it's set in openSigningWindow + expect(sidebarQueueUuids.has("test-uuid")).toBe(false); + }); + + it("removes from both sets when isActive is false", async () => { + activeQueueUuids.add("test-uuid"); + sidebarQueueUuids.add("test-uuid"); + + await popupMessageListener( + { type: "MARK_QUEUE_ACTIVE", uuid: "test-uuid", isActive: false } as any, + mockSessionStore, + mockLocalStore, + mockKeyManager, + mockSessionTimer, + { id: "fake-extension-id" }, + ); + + expect(activeQueueUuids.has("test-uuid")).toBe(false); + expect(sidebarQueueUuids.has("test-uuid")).toBe(false); + }); + + it("removes from sidebarQueueUuids even if sidebar port is no longer connected", async () => { + sidebarQueueUuids.add("test-uuid"); + activeQueueUuids.add("test-uuid"); + + // No sidebar port connected + await popupMessageListener( + { type: "MARK_QUEUE_ACTIVE", uuid: "test-uuid", isActive: false } as any, + mockSessionStore, + mockLocalStore, + mockKeyManager, + mockSessionTimer, + { id: "fake-extension-id" }, + ); + + expect(sidebarQueueUuids.has("test-uuid")).toBe(false); + }); +}); diff --git a/extension/src/background/messageListener/freighterApiMessageListener.ts b/extension/src/background/messageListener/freighterApiMessageListener.ts index bccf0eea8d..74a82fe3c5 100644 --- a/extension/src/background/messageListener/freighterApiMessageListener.ts +++ b/extension/src/background/messageListener/freighterApiMessageListener.ts @@ -24,7 +24,10 @@ import { FreighterApiInternalError, FreighterApiDeclinedError, } from "@shared/api/helpers/extensionMessaging"; -import { EXTERNAL_SERVICE_TYPES } from "@shared/constants/services"; +import { + EXTERNAL_SERVICE_TYPES, + SIDEBAR_NAVIGATE, +} from "@shared/constants/services"; import { MAINNET_NETWORK_DETAILS, NetworkDetails, @@ -66,14 +69,31 @@ import { tokenQueue, transactionQueue, } from "./popupMessageListener"; -import { QUEUE_ITEM_TTL_MS } from "background/helpers/queueCleanup"; +import { + QUEUE_ITEM_TTL_MS, + sidebarQueueUuids, +} from "background/helpers/queueCleanup"; -const SIDEBAR_NAVIGATE = "SIDEBAR_NAVIGATE"; +import { getSidebarPort } from "background/helpers/sidebarPort"; -const openSigningWindow = async (hashRoute: string, width?: number) => { +const openSigningWindow = async ( + hashRoute: string, + uuid: string, + width?: number, +) => { const sidebarWindowId = getSidebarWindowId(); if (sidebarWindowId !== null) { - browser.runtime.sendMessage({ type: SIDEBAR_NAVIGATE, route: hashRoute }); + // Track this UUID as sidebar-routed at routing time so it gets + // cleaned up on sidebar disconnect, even if the signing view + // hasn't mounted yet (e.g. ConfirmSidebarRequest interstitial). + sidebarQueueUuids.add(uuid); + + // Send navigation directly to the sidebar via its long-lived port + // instead of broadcasting to all extension listeners + const currentPort = getSidebarPort(); + if (currentPort) { + currentPort.postMessage({ type: SIDEBAR_NAVIGATE, route: hashRoute }); + } try { if ((browser as any).sidebarAction) { // Firefox @@ -88,11 +108,30 @@ const openSigningWindow = async (hashRoute: string, width?: number) => { return null; } return browser.windows.create({ - url: chrome.runtime.getURL(`/index.html#${hashRoute}`), + url: browser.runtime.getURL(`/index.html#${hashRoute}`), ...WINDOW_SETTINGS, ...(width !== undefined ? { width } : {}), }); }; + +/** Reject the dapp's pending request when the popup window is closed. */ +const rejectOnWindowClose = ( + windowId: number, + safeResolve: (value: any) => void, + rejectValue: Record = { + apiError: FreighterApiDeclinedError, + error: FreighterApiDeclinedError.message, + }, +) => { + const onWindowRemoved = (removedWindowId: number) => { + if (removedWindowId === windowId) { + browser.windows.onRemoved.removeListener(onWindowRemoved); + safeResolve(rejectValue); + } + }; + browser.windows.onRemoved.addListener(onWindowRemoved); +}; + import { DataStorageAccess } from "background/helpers/dataStorageAccess"; interface WindowParams { @@ -135,13 +174,23 @@ export const freighterApiMessageListener = ( const uuid = crypto.randomUUID(); const encodeOrigin = encodeObject({ tab, url: tabUrl, uuid }); - const popup = await openSigningWindow(`/grant-access?${encodeOrigin}`); + const popup = await openSigningWindow( + `/grant-access?${encodeOrigin}`, + uuid, + ); return new Promise((resolve) => { + let resolved = false; + const safeResolve = (value: any) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + if (popup === null) { setTimeout( () => - resolve({ + safeResolve({ apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, }), @@ -164,10 +213,11 @@ export const freighterApiMessageListener = ( } }, 50); - resolve({ publicKey }); + safeResolve({ publicKey }); + return; } - resolve({ + safeResolve({ // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, @@ -233,28 +283,37 @@ export const freighterApiMessageListener = ( tokenQueue.push({ token: tokenInfo, uuid, createdAt: Date.now() }); const encodedTokenInfo = encodeObject(tokenInfo); - const popup = await openSigningWindow(`/add-token?${encodedTokenInfo}`); + const popup = await openSigningWindow( + `/add-token?${encodedTokenInfo}`, + uuid, + ); return new Promise((resolve) => { + let resolved = false; + const safeResolve = (value: any) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + if (popup === undefined) { - resolve({ + safeResolve({ apiError: FreighterApiInternalError, }); } else if (popup !== null) { - browser.windows.onRemoved.addListener(() => - resolve({ - apiError: FreighterApiDeclinedError, - }), - ); + rejectOnWindowClose(popup.id!, safeResolve, { + apiError: FreighterApiDeclinedError, + }); } const response = (success: boolean) => { if (success) { - resolve({ + safeResolve({ contractId, }); + return; } - resolve({ + safeResolve({ apiError: FreighterApiDeclinedError, }); }; @@ -380,11 +439,21 @@ export const freighterApiMessageListener = ( }); const encodedBlob = encodeObject(transactionInfo); - const popup = await openSigningWindow(`/sign-transaction?${encodedBlob}`); + const popup = await openSigningWindow( + `/sign-transaction?${encodedBlob}`, + uuid, + ); return new Promise((resolve) => { + let resolved = false; + const safeResolve = (value: any) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + if (popup === undefined) { - resolve({ + safeResolve({ // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface apiError: FreighterApiInternalError, error: FreighterApiInternalError.message, @@ -392,30 +461,25 @@ export const freighterApiMessageListener = ( } else if (popup === null) { setTimeout( () => - resolve({ + safeResolve({ apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, }), QUEUE_ITEM_TTL_MS, ); } else { - browser.windows.onRemoved.addListener(() => - resolve({ - // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface - apiError: FreighterApiDeclinedError, - error: FreighterApiDeclinedError.message, - }), - ); + rejectOnWindowClose(popup.id!, safeResolve); } const response = ( signedTransaction: string, signerAddress?: string, ) => { if (signedTransaction) { - resolve({ signedTransaction, signerAddress }); + safeResolve({ signedTransaction, signerAddress }); + return; } - resolve({ + safeResolve({ // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, @@ -461,11 +525,21 @@ export const freighterApiMessageListener = ( blobQueue.push({ blob: blobData, uuid, createdAt: Date.now() }); const encodedBlob = encodeObject(blobData); - const popup = await openSigningWindow(`/sign-message?${encodedBlob}`); + const popup = await openSigningWindow( + `/sign-message?${encodedBlob}`, + uuid, + ); return new Promise((resolve) => { + let resolved = false; + const safeResolve = (value: any) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + if (popup === undefined) { - resolve({ + safeResolve({ // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface apiError: FreighterApiInternalError, error: FreighterApiInternalError.message, @@ -473,20 +547,14 @@ export const freighterApiMessageListener = ( } else if (popup === null) { setTimeout( () => - resolve({ + safeResolve({ apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, }), QUEUE_ITEM_TTL_MS, ); } else { - browser.windows.onRemoved.addListener(() => - resolve({ - // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface - apiError: FreighterApiDeclinedError, - error: FreighterApiDeclinedError.message, - }), - ); + rejectOnWindowClose(popup.id!, safeResolve); } const response = ( @@ -495,16 +563,17 @@ export const freighterApiMessageListener = ( ) => { if (signedBlob) { if (apiVersion && semver.gte(apiVersion, "4.0.0")) { - resolve({ + safeResolve({ signedBlob: signedBlob.toString("base64"), signerAddress, }); return; } - resolve({ signedBlob, signerAddress }); + safeResolve({ signedBlob, signerAddress }); + return; } - resolve({ + safeResolve({ // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, @@ -556,11 +625,19 @@ export const freighterApiMessageListener = ( const encodedAuthEntry = encodeObject(authEntry); const popup = await openSigningWindow( `/sign-auth-entry?${encodedAuthEntry}`, + uuid, ); return new Promise((resolve) => { + let resolved = false; + const safeResolve = (value: any) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + if (popup === undefined) { - resolve({ + safeResolve({ // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface apiError: FreighterApiInternalError, error: FreighterApiInternalError.message, @@ -568,20 +645,14 @@ export const freighterApiMessageListener = ( } else if (popup === null) { setTimeout( () => - resolve({ + safeResolve({ apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, }), QUEUE_ITEM_TTL_MS, ); } else { - browser.windows.onRemoved.addListener(() => - resolve({ - // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface - apiError: FreighterApiDeclinedError, - error: FreighterApiDeclinedError.message, - }), - ); + rejectOnWindowClose(popup.id!, safeResolve); } const response = ( signedAuthEntry: SignAuthEntryResponse, @@ -589,17 +660,18 @@ export const freighterApiMessageListener = ( ) => { if (signedAuthEntry) { if (apiVersion && semver.gte(apiVersion, "4.2.0")) { - resolve({ + safeResolve({ signedAuthEntry: Buffer.from(signedAuthEntry).toString("base64"), signerAddress, }); return; } - resolve({ signedAuthEntry, signerAddress }); + safeResolve({ signedAuthEntry, signerAddress }); + return; } - resolve({ + safeResolve({ // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, @@ -698,17 +770,30 @@ export const freighterApiMessageListener = ( const uuid = crypto.randomUUID(); const encodeOrigin = encodeObject({ tab, url: tabUrl, uuid }); - await openSigningWindow(`/grant-access?${encodeOrigin}`, 400); + const popup = await openSigningWindow( + `/grant-access?${encodeOrigin}`, + uuid, + 400, + ); return new Promise((resolve) => { - setTimeout( - () => - resolve({ - apiError: FreighterApiDeclinedError, - error: FreighterApiDeclinedError.message, - }), - QUEUE_ITEM_TTL_MS, - ); + let resolved = false; + const safeResolve = (value: any) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + + if (popup === null) { + setTimeout( + () => + safeResolve({ + apiError: FreighterApiDeclinedError, + error: FreighterApiDeclinedError.message, + }), + QUEUE_ITEM_TTL_MS, + ); + } const response = async (url?: string) => { // queue it up, we'll let user confirm the url looks okay and then we'll say it's okay if (url === tabUrl) { @@ -722,10 +807,11 @@ export const freighterApiMessageListener = ( allowListSegment: updatedAllAccountsllowListSegment, }); - resolve({ isAllowed: isAllowedResponse }); + safeResolve({ isAllowed: isAllowedResponse }); + return; } - resolve({ + safeResolve({ // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface apiError: FreighterApiDeclinedError, error: FreighterApiDeclinedError.message, diff --git a/extension/src/background/messageListener/handlers/rejectSigningRequest.ts b/extension/src/background/messageListener/handlers/rejectSigningRequest.ts new file mode 100644 index 0000000000..7a0e63db23 --- /dev/null +++ b/extension/src/background/messageListener/handlers/rejectSigningRequest.ts @@ -0,0 +1,79 @@ +import { + RejectSigningRequestMessage, + ResponseQueue, + TransactionQueue, + BlobQueue, + EntryQueue, + TokenQueue, + RequestAccessResponse, + SignTransactionResponse, + SignBlobResponse, + SignAuthEntryResponse, + AddTokenResponse, + SetAllowedStatusResponse, +} from "@shared/api/types/message-request"; +import { captureException } from "@sentry/browser"; + +type AnySigningResponse = + | RequestAccessResponse + | SignTransactionResponse + | SignBlobResponse + | SignAuthEntryResponse + | AddTokenResponse + | SetAllowedStatusResponse + | undefined; + +/** + * Rejects a pending signing request by UUID, removing it from every queue + * (responseQueue, transactionQueue, blobQueue, authEntryQueue, tokenQueue). + * This ensures the dapp's promise resolves immediately rather than waiting + * for the TTL timeout. + */ +export const rejectSigningRequest = ({ + request, + responseQueue, + transactionQueue, + blobQueue, + authEntryQueue, + tokenQueue, +}: { + request: RejectSigningRequestMessage; + responseQueue: ResponseQueue; + transactionQueue: TransactionQueue; + blobQueue: BlobQueue; + authEntryQueue: EntryQueue; + tokenQueue: TokenQueue; +}) => { + const { uuid } = request; + + if (!uuid) { + captureException("rejectSigningRequest: missing uuid in request"); + return {}; + } + + // Resolve (reject) the dapp's pending promise + const responseIndex = responseQueue.findIndex((item) => item.uuid === uuid); + if (responseIndex !== -1) { + const responseQueueItem = responseQueue.splice(responseIndex, 1)[0]; + responseQueueItem.response(undefined); + } else { + captureException( + `rejectSigningRequest: no matching response found for uuid ${uuid}`, + ); + } + + // Clean up all data queues so stale entries don't accumulate + const txIndex = transactionQueue.findIndex((item) => item.uuid === uuid); + if (txIndex !== -1) transactionQueue.splice(txIndex, 1); + + const blobIndex = blobQueue.findIndex((item) => item.uuid === uuid); + if (blobIndex !== -1) blobQueue.splice(blobIndex, 1); + + const authIndex = authEntryQueue.findIndex((item) => item.uuid === uuid); + if (authIndex !== -1) authEntryQueue.splice(authIndex, 1); + + const tokenIndex = tokenQueue.findIndex((item) => item.uuid === uuid); + if (tokenIndex !== -1) tokenQueue.splice(tokenIndex, 1); + + return {}; +}; diff --git a/extension/src/background/messageListener/handlers/saveSettings.ts b/extension/src/background/messageListener/handlers/saveSettings.ts index d58e0260b2..f89d7a757f 100644 --- a/extension/src/background/messageListener/handlers/saveSettings.ts +++ b/extension/src/background/messageListener/handlers/saveSettings.ts @@ -59,8 +59,8 @@ export const saveSettings = async ({ isSorobanPublicEnabled: featureFlags.useSorobanPublic, isNonSSLEnabled: await getIsNonSSLEnabled({ localStore }), isHideDustEnabled: await getIsHideDustEnabled({ localStore }), - isOpenSidebarByDefault: (await localStore.getItem( - IS_OPEN_SIDEBAR_BY_DEFAULT_ID, - )) as boolean ?? false, + isOpenSidebarByDefault: + ((await localStore.getItem(IS_OPEN_SIDEBAR_BY_DEFAULT_ID)) as boolean) ?? + false, }; }; diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 558450ac21..01fb74c407 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill"; import { Store } from "redux"; import { ResponseQueue, @@ -16,7 +17,6 @@ import { RejectTransactionResponse, SignedHwPayloadResponse, MarkQueueActiveMessage, - SidebarRegisterMessage, OpenSidebarMessage, } from "@shared/api/types/message-request"; import { SERVICE_TYPES } from "@shared/constants/services"; @@ -27,6 +27,7 @@ import { publicKeySelector } from "background/ducks/session"; import { startQueueCleanup, activeQueueUuids, + sidebarQueueUuids, } from "background/helpers/queueCleanup"; import { fundAccount } from "./handlers/fundAccount"; @@ -55,6 +56,7 @@ import { signTransaction } from "./handlers/signTransaction"; import { signBlob } from "./handlers/signBlob"; import { signAuthEntry } from "./handlers/signAuthEntry"; import { rejectTransaction } from "./handlers/rejectTransaction"; +import { rejectSigningRequest } from "./handlers/rejectSigningRequest"; import { signFreighterTransaction } from "./handlers/signFreighterTransaction"; import { addRecentAddress } from "./handlers/addRecentAddress"; import { loadRecentAddresses } from "./handlers/loadRecentAddresses"; @@ -135,10 +137,17 @@ export const popupMessageListener = ( localStore: DataStorageAccess, keyManager: KeyManager, sessionTimer: SessionTimer, + sender: { tab?: unknown; id?: 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. + const isFromExtensionPage = + !sender.tab && (!sender.id || sender.id === browser?.runtime?.id); + if ( request.activePublicKey && request.activePublicKey !== publicKey && @@ -342,6 +351,17 @@ export const popupMessageListener = ( transactionQueue, }); } + case SERVICE_TYPES.REJECT_SIGNING_REQUEST: { + if (!isFromExtensionPage) return { error: "Unauthorized" }; + return rejectSigningRequest({ + request, + responseQueue, + transactionQueue, + blobQueue, + authEntryQueue, + tokenQueue, + }); + } case SERVICE_TYPES.SIGN_FREIGHTER_TRANSACTION: { return signFreighterTransaction({ request, @@ -559,14 +579,20 @@ export const popupMessageListener = ( const { uuid, isActive } = request as MarkQueueActiveMessage; if (isActive) { activeQueueUuids.add(uuid); + // sidebarQueueUuids is populated at routing time in + // openSigningWindow, not here — this avoids missing requests + // that are behind the ConfirmSidebarRequest interstitial. } else { activeQueueUuids.delete(uuid); + sidebarQueueUuids.delete(uuid); } return {}; } case SERVICE_TYPES.OPEN_SIDEBAR: { + if (!isFromExtensionPage) return { error: "Unauthorized" }; const { windowId } = request as OpenSidebarMessage; + if (typeof windowId !== "number") return { error: "Invalid windowId" }; return (async () => { await chrome.sidePanel .setOptions({ path: "index.html?mode=sidebar", enabled: true }) @@ -578,16 +604,6 @@ export const popupMessageListener = ( })(); } - case SERVICE_TYPES.SIDEBAR_REGISTER: { - sidebarWindowId = (request as SidebarRegisterMessage).windowId; - return {}; - } - - case SERVICE_TYPES.SIDEBAR_UNREGISTER: { - sidebarWindowId = null; - return {}; - } - default: return { error: "Message type not supported" }; } diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index 93bd7d6250..5edee2e6d0 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -62,6 +62,7 @@ import { AccountMigration } from "popup/views/AccountMigration"; import { AddFunds } from "popup/views/AddFunds"; import { Discover } from "popup/views/Discover"; import { Wallets } from "popup/views/Wallets"; +import { ConfirmSidebarRequest } from "popup/views/ConfirmSidebarRequest"; import { DEV_SERVER } from "@shared/constants/services"; import { isSidebarMode } from "popup/helpers/isSidebarMode"; @@ -205,6 +206,10 @@ export const Router = () => ( element={} > }> + } + > } diff --git a/extension/src/popup/components/SidebarSigningListener/index.tsx b/extension/src/popup/components/SidebarSigningListener/index.tsx index 14e2408186..fc889056e8 100644 --- a/extension/src/popup/components/SidebarSigningListener/index.tsx +++ b/extension/src/popup/components/SidebarSigningListener/index.tsx @@ -1,20 +1,44 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import browser from "webextension-polyfill"; import { ROUTES } from "popup/constants/routes"; +import { SIDEBAR_NAVIGATE } from "@shared/constants/services"; -const SIDEBAR_NAVIGATE = "SIDEBAR_NAVIGATE"; export const SIDEBAR_PORT_NAME = "sidebar"; +// Routes that represent active signing/approval flows. +// When a new SIDEBAR_NAVIGATE arrives while the user is already on one of +// these, we show an interstitial instead of silently swapping the screen. +const SIGNING_ROUTE_PREFIXES = [ + ROUTES.signTransaction, + ROUTES.signAuthEntry, + ROUTES.signMessage, + ROUTES.grantAccess, + ROUTES.addToken, + ROUTES.reviewAuthorization, + ROUTES.confirmSidebarRequest, +]; + +// Only allow navigation to known signing-related routes (defense-in-depth). +const ALLOWED_NAV_PREFIXES = [ + ROUTES.signTransaction, + ROUTES.signAuthEntry, + ROUTES.signMessage, + ROUTES.grantAccess, + ROUTES.addToken, + ROUTES.reviewAuthorization, +]; + export const SidebarSigningListener = () => { const navigate = useNavigate(); useEffect(() => { // Open a long-lived port to the background. // The background uses onDisconnect to reliably clear sidebarWindowId when sidebar closes. - const port = chrome.runtime.connect({ name: SIDEBAR_PORT_NAME }); + const port = browser.runtime.connect({ name: SIDEBAR_PORT_NAME }); // Send window ID so the background can register this sidebar - chrome.windows.getCurrent().then((win) => { + browser.windows.getCurrent().then((win) => { port.postMessage({ windowId: win.id }); }); @@ -22,17 +46,40 @@ export const SidebarSigningListener = () => { const originalClose = window.close.bind(window); window.close = () => navigate(ROUTES.account); - const handler = (message: { type: string; route: string }) => { - if (message.type === SIDEBAR_NAVIGATE) { - navigate(message.route); + // Listen for navigation messages sent directly over the port from the + // background, scoped to this sidebar only (no broadcast to other listeners). + const portHandler = (message: unknown) => { + if (typeof message !== "object" || message === null) return; + const { type, route } = message as Record; + if (type !== SIDEBAR_NAVIGATE || typeof route !== "string") return; + + // Only allow navigation to known signing routes + if (!ALLOWED_NAV_PREFIXES.some((prefix) => route.startsWith(prefix))) { + return; + } + + // If the user is already reviewing a signing request, show an + // interstitial so they consciously acknowledge the new request + // rather than having the screen silently swap underneath them. + const currentHash = window.location.hash.replace("#", ""); + const isOnSigningRoute = SIGNING_ROUTE_PREFIXES.some((prefix) => + currentHash.startsWith(prefix), + ); + + if (isOnSigningRoute) { + navigate( + `${ROUTES.confirmSidebarRequest}?next=${encodeURIComponent(route)}`, + ); + } else { + navigate(route); } }; - chrome.runtime.onMessage.addListener(handler); + port.onMessage.addListener(portHandler); return () => { window.close = originalClose; - chrome.runtime.onMessage.removeListener(handler); + port.onMessage.removeListener(portHandler); port.disconnect(); }; }, [navigate]); diff --git a/extension/src/popup/components/account/AccountHeader/index.tsx b/extension/src/popup/components/account/AccountHeader/index.tsx index ee907d06a4..efc58d6f68 100644 --- a/extension/src/popup/components/account/AccountHeader/index.tsx +++ b/extension/src/popup/components/account/AccountHeader/index.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate, NavLink } from "react-router-dom"; import { createPortal } from "react-dom"; +import browser from "webextension-polyfill"; import { Icon, Text, NavButton, CopyText } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; @@ -181,7 +182,10 @@ export const AccountHeader = ({ - {typeof globalThis.chrome?.sidePanel?.open === "function" && ( + {(typeof globalThis.chrome?.sidePanel?.open === + "function" || + typeof (browser as any)?.sidebarAction?.open === + "function") && (
openSidebar()} diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 675e8b3969..4132b5fce8 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -182,4 +182,5 @@ export const METRIC_NAMES = { coinbaseOnrampOpened: "coinbase onramp: opened", wallets: "loaded screen: wallets", + confirmSidebarRequest: "loaded screen: confirm sidebar request", }; diff --git a/extension/src/popup/constants/routes.ts b/extension/src/popup/constants/routes.ts index 69a65d11af..5cc991f943 100644 --- a/extension/src/popup/constants/routes.ts +++ b/extension/src/popup/constants/routes.ts @@ -53,6 +53,7 @@ export enum ROUTES { accountMigrationConfirmMigration = "/account-migration/confirm-migration", accountMigrationMigrationComplete = "/account-migration/migration-complete", + confirmSidebarRequest = "/confirm-sidebar-request", discover = "/discover", wallets = "/wallets", } diff --git a/extension/src/popup/helpers/isSidebarMode.ts b/extension/src/popup/helpers/isSidebarMode.ts index 1100522554..998e2ff933 100644 --- a/extension/src/popup/helpers/isSidebarMode.ts +++ b/extension/src/popup/helpers/isSidebarMode.ts @@ -1,2 +1,5 @@ +// Guard against non-extension contexts: extension pages always load from +// chrome-extension:// (or moz-extension://), never from web origins. export const isSidebarMode = () => + /^(chrome|moz)-extension:$/.test(window.location.protocol) && new URLSearchParams(window.location.search).get("mode") === "sidebar"; diff --git a/extension/src/popup/helpers/navigate.ts b/extension/src/popup/helpers/navigate.ts index dedbef588a..7a95dc1644 100644 --- a/extension/src/popup/helpers/navigate.ts +++ b/extension/src/popup/helpers/navigate.ts @@ -29,8 +29,9 @@ export const openSidebar = async () => { }); await chrome.sidePanel.open({ windowId: win.id! }); } + window.close(); } catch (e) { console.error("Failed to open sidebar:", e); + window.close(); } - window.close(); }; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 9dcf9861ac..973dc26c0a 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -3,6 +3,7 @@ "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", "* payment methods may vary based on your location": "* payment methods may vary based on your location", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.", + "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "A new signing request arrived while you were reviewing another. Please review it carefully before approving.", "About": "About", "Account": "Account", "Account details": "Account details", @@ -127,6 +128,7 @@ "CONNECTION ERROR": "CONNECTION ERROR", "Connection Request": "Connection Request", "Continue": "Continue", + "Continue to review": "Continue to review", "Contract Address": "Contract Address", "Contract creation": "Contract creation", "Contract Function": "Contract Function", @@ -366,6 +368,7 @@ "Nevermind, cancel": "Nevermind, cancel", "New account": "New account", "New password": "New password", + "New Signing Request": "New Signing Request", "No": "No", "No collectibles yet": "No collectibles yet", "No connected apps found": "No connected apps found", @@ -390,6 +393,8 @@ "Only confirm if you trust this site": "Only confirm if you trust this site", "Only utilize these features if you can understand and manage the potential security risks": "Only utilize these features if you can understand and manage the potential security risks.", "Open": "Open", + "Open Freighter in sidebar instead of popup when clicking the extension icon": "Open Freighter in sidebar instead of popup when clicking the extension icon", + "Open sidebar mode by default": "Open sidebar mode by default", "Open status page": "Open status page", "Operation": "Operation", "Operations": "Operations", @@ -433,6 +438,7 @@ "Recovery Phrase": "Recovery Phrase", "Refresh": "Refresh", "Refresh metadata": "Refresh metadata", + "Reject": "Reject", "Remember, Freighter will now display accounts related to the new backup phrase that was just created.": "Remember, Freighter will now display accounts related to the new backup phrase that was just created.", "Remove": "Remove", "Remove asset": "Remove asset", @@ -444,6 +450,7 @@ "Report issue on Github": "Report issue on Github", "Reserved Balance*": "Reserved Balance*", "Review accounts to migrate": "Review accounts to migrate", + "Review Request": "Review Request", "Review Send": "Review Send", "Review swap": "Review swap", "Review transaction on device": "Review transaction on device", @@ -484,6 +491,7 @@ "Should be benign": "Should be benign", "Show collectible": "Show collectible", "Show recovery phrase": "Show recovery phrase", + "Sidebar mode": "Sidebar mode", "Signed Payload": "Signed Payload", "Signer": "Signer", "Signer Key": "Signer Key", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 6d79ea39a2..69b3bf01f9 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -3,6 +3,7 @@ "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", "* payment methods may vary based on your location": "* Os métodos de pagamento podem variar com base na sua localização", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "Uma conta de destino requer o uso do campo memo que não está presente na transação que você está prestes a assinar.", + "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "Uma nova solicitação de assinatura chegou enquanto você revisava outra. Revise-a com atenção antes de aprovar.", "About": "Sobre", "Account": "Conta", "Account details": "Detalhes da conta", @@ -127,6 +128,7 @@ "CONNECTION ERROR": "ERRO DE CONEXÃO", "Connection Request": "Solicitação de Conexão", "Continue": "Continuar", + "Continue to review": "Continuar a revisar", "Contract Address": "Endereço do Contrato", "Contract creation": "Criação de contrato", "Contract Function": "Função do Contrato", @@ -368,6 +370,7 @@ "Nevermind, cancel": "Deixa pra lá, cancelar", "New account": "Nova conta", "New password": "Nova senha", + "New Signing Request": "Nova Solicitação de Assinatura", "No": "Não", "No collectibles yet": "Ainda não há colecionáveis", "No connected apps found": "Nenhum app conectado encontrado", @@ -392,6 +395,8 @@ "Only confirm if you trust this site": "Apenas confirme se você confia neste site", "Only utilize these features if you can understand and manage the potential security risks": "Utilize esses recursos apenas se você puder entender e gerenciar os riscos de segurança potenciais.", "Open": "Abrir", + "Open Freighter in sidebar instead of popup when clicking the extension icon": "Abrir o Freighter na barra lateral em vez do popup ao clicar no ícone da extensão", + "Open sidebar mode by default": "Abrir no modo barra lateral por padrão", "Open status page": "Abrir página de status", "Operation": "Operação", "Operations": "Operações", @@ -435,6 +440,7 @@ "Recovery Phrase": "Frase de Recuperação", "Refresh": "Atualizar", "Refresh metadata": "Atualizar metadados", + "Reject": "Rejeitar", "Remember, Freighter will now display accounts related to the new backup phrase that was just created.": "Lembre-se, o Freighter agora exibirá contas relacionadas à nova frase de backup que acabou de ser criada.", "Remove": "Remover", "Remove asset": "Remover ativo", @@ -446,6 +452,7 @@ "Report issue on Github": "Reportar issue no Github", "Reserved Balance*": "Saldo Reservado*", "Review accounts to migrate": "Revisar contas para migrar", + "Review Request": "Revisar Solicitação", "Review Send": "Revisar Envio", "Review swap": "Revisar troca", "Review transaction on device": "Revisar transação no dispositivo", @@ -486,6 +493,7 @@ "Should be benign": "Deve ser benigno", "Show collectible": "Mostrar colecionável", "Show recovery phrase": "Mostrar frase de recuperação", + "Sidebar mode": "Modo barra lateral", "Signed Payload": "Payload Assinado", "Signer": "Assinante", "Signer Key": "Chave do Assinante", diff --git a/extension/src/popup/metrics/views.ts b/extension/src/popup/metrics/views.ts index 0d63f6fa91..1bcd9191f7 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -3,6 +3,7 @@ import { METRIC_NAMES } from "popup/constants/metricsNames"; import { registerHandler, emitMetric } from "helpers/metrics"; import { getTransactionInfo } from "helpers/stellar"; import { parsedSearchParam, getUrlHostname, getUrlDomain } from "helpers/urls"; +import { isSidebarMode } from "popup/helpers/isSidebarMode"; import { navigate } from "popup/ducks/views"; import { AppState } from "popup/App"; @@ -68,6 +69,7 @@ const routeToEventName = { [ROUTES.addFunds]: METRIC_NAMES.viewAddFunds, [ROUTES.discover]: METRIC_NAMES.discover, [ROUTES.wallets]: METRIC_NAMES.wallets, + [ROUTES.confirmSidebarRequest]: METRIC_NAMES.confirmSidebarRequest, }; registerHandler(navigate, (_, a) => { @@ -82,11 +84,14 @@ registerHandler(navigate, (_, a) => { } // "/sign-transaction" and "/grant-access" require additional metrics on loaded page + const isSidebarModeActivated = isSidebarMode(); + if (pathname === ROUTES.grantAccess) { const { url } = parsedSearchParam(search); const METRIC_OPTION_DOMAIN = { domain: getUrlDomain(url), subdomain: getUrlHostname(url), + sidebarMode: isSidebarModeActivated, }; emitMetric(eventName, METRIC_OPTION_DOMAIN); @@ -95,6 +100,7 @@ registerHandler(navigate, (_, a) => { const METRIC_OPTIONS = { domain: getUrlDomain(url), subdomain: getUrlHostname(url), + sidebarMode: isSidebarModeActivated, }; emitMetric(eventName, METRIC_OPTIONS); @@ -106,7 +112,7 @@ registerHandler(navigate, (_, a) => { const METRIC_OPTIONS = { domain: getUrlDomain(url), subdomain: getUrlHostname(url), - + sidebarMode: isSidebarModeActivated, number_of_operations: operations.length, operationTypes, }; @@ -121,6 +127,7 @@ registerHandler(navigate, (_, a) => { const METRIC_OPTIONS = { domain: getUrlDomain(url), subdomain: getUrlHostname(url), + sidebarMode: isSidebarModeActivated, }; emitMetric(eventName, METRIC_OPTIONS); diff --git a/extension/src/popup/views/ConfirmSidebarRequest/index.tsx b/extension/src/popup/views/ConfirmSidebarRequest/index.tsx new file mode 100644 index 0000000000..e8f0d9f73e --- /dev/null +++ b/extension/src/popup/views/ConfirmSidebarRequest/index.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Button, Icon } from "@stellar/design-system"; + +import { ROUTES } from "popup/constants/routes"; +import { View } from "popup/basics/layout/View"; +import { rejectSigningRequest } from "@shared/api/internal"; +import { parsedSearchParam } from "helpers/urls"; + +import "./styles.scss"; + +export const ConfirmSidebarRequest = () => { + const { t } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); + + const params = new URLSearchParams(location.search); + const next = params.get("next") || ""; + + // Only allow safe, in-extension routes for "next"; fall back to account route. + const isValidNextRoute = (value: string) => { + if (!value) { + return false; + } + // Require a single leading "/" (internal path), disallow "//" and any URI scheme. + if (!value.startsWith("/") || value.startsWith("//")) { + return false; + } + // Disallow strings that look like they start with a URI scheme (e.g., "http:", "javascript:"). + if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(value)) { + return false; + } + return true; + }; + + const safeNext = isValidNextRoute(next) ? next : ROUTES.account; + + const handleReview = () => { + navigate(safeNext); + }; + const handleReject = async () => { + // Extract the UUID from the incoming request's encoded route and reject it + // via a dedicated handler that cleans up ALL queues (responseQueue, + // transactionQueue, blobQueue, authEntryQueue, tokenQueue) so the dapp's + // promise resolves immediately instead of hanging until the TTL fires. + try { + const nextRoute = isValidNextRoute(next) ? next : ""; + const queryString = nextRoute.split("?")[1] || ""; + if (queryString) { + const parsed = parsedSearchParam(queryString); + if (parsed.uuid) { + await rejectSigningRequest({ uuid: parsed.uuid }); + } + } + } catch { + // Best-effort — navigate home regardless + } + navigate(ROUTES.account); + }; + + return ( + +
+
+
+ +
+

+ {t("New Signing Request")} +

+
+ {t( + "A new signing request arrived while you were reviewing another. Please review it carefully before approving.", + )} +
+
+
+ + +
+
+
+ ); +}; diff --git a/extension/src/popup/views/ConfirmSidebarRequest/styles.scss b/extension/src/popup/views/ConfirmSidebarRequest/styles.scss new file mode 100644 index 0000000000..3a8b50a06e --- /dev/null +++ b/extension/src/popup/views/ConfirmSidebarRequest/styles.scss @@ -0,0 +1,61 @@ +@use "../../styles/utils.scss" as *; + +.ConfirmSidebarRequest { + display: flex; + flex-direction: column; + gap: pxToRem(32px); + padding: pxToRem(24px); + height: 100%; + + &__header { + display: flex; + flex-direction: column; + gap: pxToRem(16px); + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: pxToRem(32px); + height: pxToRem(32px); + border-radius: pxToRem(8px); + background-color: var(--sds-clr-amber-03); + border: pxToRem(1px) solid var(--sds-clr-amber-07); + + svg { + width: pxToRem(20px); + height: pxToRem(20px); + color: var(--sds-clr-amber-11); + } + } + + &__title { + font-size: pxToRem(18px); + font-weight: 500; + line-height: pxToRem(26px); + color: var(--sds-clr-gray-12); + margin: 0; + } + + &__body { + font-family: "Inter", sans-serif; + font-size: pxToRem(14px); + font-weight: 400; + font-style: normal; + line-height: pxToRem(20px); + color: var(--sds-clr-gray-11); + margin: 0; + } + + &__buttons { + display: flex; + flex-direction: column; + gap: pxToRem(12px); + width: 100%; + + button { + border-radius: pxToRem(100px); + } + } +}