From a43b0d62cf57efd2c3a5bfa08c6f09ec493a130e Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Tue, 23 Jun 2026 12:09:30 +0200 Subject: [PATCH] Persist auto-lock activity across worker restarts --- docs/SECURITY.md | 3 + src/background/autoLock.test.js | 421 ++++++++++++++++++++++ src/background/dappEvents.test.js | 9 + src/background/index.js | 140 ++++++- src/background/rpc.test.js | 42 +++ src/background/txLifecycle.flow.test.js | 3 + src/integration/providerDiscovery.test.js | 89 ++++- src/platform/extensionApi.js | 12 + src/shared/storage.js | 1 + 9 files changed, 712 insertions(+), 8 deletions(-) create mode 100644 src/background/autoLock.test.js diff --git a/docs/SECURITY.md b/docs/SECURITY.md index cba61e0..06652fc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -73,6 +73,9 @@ The wallet automatically locks after a configurable timeout: | Default | 5 minutes | Implementation uses `chrome.alarms` API for reliable timing even when service worker sleeps. +The last activity timestamp is persisted in extension session storage when available +(falling back to local storage outside extension contexts), so a restarted background +service worker does not immediately lock an otherwise active unlocked wallet. ### 3. dApp Permissions diff --git a/src/background/autoLock.test.js b/src/background/autoLock.test.js new file mode 100644 index 0000000..6962f20 --- /dev/null +++ b/src/background/autoLock.test.js @@ -0,0 +1,421 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const AUTO_LOCK_ACTIVITY_KEY = "dusk_auto_lock_activity_v1"; +const AUTO_LOCK_ALARM_NAME = "dusk_auto_lock_check"; + +const mocks = vi.hoisted(() => { + const sessionStore = new Map(); + + function storageGet(keys) { + if (typeof keys === "string") { + return sessionStore.has(keys) ? { [keys]: sessionStore.get(keys) } : {}; + } + if (Array.isArray(keys)) { + const out = {}; + for (const key of keys) { + if (sessionStore.has(key)) out[key] = sessionStore.get(key); + } + return out; + } + if (keys && typeof keys === "object") { + const out = {}; + for (const [key, fallback] of Object.entries(keys)) { + out[key] = sessionStore.has(key) ? sessionStore.get(key) : fallback; + } + return out; + } + return Object.fromEntries(sessionStore); + } + + return { + listener: null, + alarmListener: null, + sessionStore, + settings: { + autoLockTimeoutMinutes: 5, + nodeUrl: "https://testnet.nodes.dusk.network", + }, + engineUnlocked: true, + now: 1_000_000, + sentMessages: [], + alarmsClear: vi.fn(async () => true), + alarmsCreate: vi.fn(() => {}), + engineCall: vi.fn(async (method) => { + if (method === "engine_unlock") { + return { accounts: ["acct0"] }; + } + if (method === "engine_lock") { + mocks.engineUnlocked = false; + } + return true; + }), + getEngineStatus: vi.fn(async () => ({ + isUnlocked: mocks.engineUnlocked, + accounts: ["acct0"], + addresses: ["addr0"], + selectedAccountIndex: 0, + })), + broadcastProfilesChangedAll: vi.fn(async () => {}), + handleRpc: vi.fn(async (_origin, request) => { + if (request?.method === "dusk_switchNetwork") { + const nextNodeUrl = String(request?.params?.nodeUrl ?? "").trim(); + if (nextNodeUrl) { + mocks.settings = { ...mocks.settings, nodeUrl: nextNodeUrl }; + } + } + return { method: request?.method ?? "" }; + }), + runtimeSendMessage: vi.fn(async (message) => { + mocks.sentMessages.push(message); + return { ok: true }; + }), + storageSessionGet: vi.fn(async (keys) => storageGet(keys)), + storageSessionSet: vi.fn(async (items) => { + for (const [key, value] of Object.entries(items ?? {})) { + sessionStore.set(key, value); + } + }), + storageSessionRemove: vi.fn(async (keys) => { + for (const key of Array.isArray(keys) ? keys : [keys]) { + sessionStore.delete(key); + } + }), + }; +}); + +vi.mock("../shared/settings.js", () => ({ + getSettings: vi.fn(async () => mocks.settings), + setSettings: vi.fn(async (patch) => { + mocks.settings = { ...mocks.settings, ...patch }; + return mocks.settings; + }), +})); + +vi.mock("../shared/vault.js", () => ({ + createVault: vi.fn(async () => true), + loadVault: vi.fn(async () => ({ v: 1 })), + unlockVault: vi.fn(async () => "mnemonic"), +})); + +vi.mock("../shared/permissions.js", () => ({ + approveOrigin: vi.fn(async () => true), + getPermissionForOrigin: vi.fn(async () => ({ + profileId: "account:0:acct0", + accountIndex: 0, + grants: { publicAccount: true, shieldedReceiveAddress: false }, + })), + getPermissions: vi.fn(async () => ({})), + revokeOrigin: vi.fn(async () => true), +})); + +vi.mock("./engineHost.js", () => ({ + engineCall: mocks.engineCall, + ensureEngineConfigured: vi.fn(async () => true), + getEngineStatus: mocks.getEngineStatus, + invalidateEngineConfig: vi.fn(() => {}), + handleEngineReady: vi.fn(() => {}), +})); + +vi.mock("./rpc.js", () => ({ + handleRpc: mocks.handleRpc, +})); + +vi.mock("./pending.js", () => ({ + getPending: vi.fn(() => null), + resolvePendingDecision: vi.fn(() => ({ ok: true })), +})); + +vi.mock("./dappEvents.js", () => ({ + broadcastChainChangedAll: vi.fn(async () => {}), + broadcastProfilesChangedAll: mocks.broadcastProfilesChangedAll, + bindPortsForSenderOrigin: vi.fn(() => {}), + registerDappPort: vi.fn(() => {}), + registerStorageChangeForwarder: vi.fn(() => {}), +})); + +vi.mock("./txNotify.js", () => ({ + notifyTxSubmitted: vi.fn(async () => true), + notifyTxExecuted: vi.fn(async () => true), + registerTxNotificationHandlers: vi.fn(() => {}), +})); + +vi.mock("../shared/accountNames.js", () => ({ + getAccountNames: vi.fn(async () => ({})), +})); + +vi.mock("../shared/assetsStore.js", () => ({ + getWatchedAssets: vi.fn(async () => ({ tokens: [], nfts: [] })), + watchToken: vi.fn(async () => true), + unwatchToken: vi.fn(async () => true), + watchNft: vi.fn(async () => true), + unwatchNft: vi.fn(async () => true), +})); + +vi.mock("../shared/networkStatus.js", () => ({ + getNetworkStatus: vi.fn(async () => ({ checkedAt: 0 })), + checkAllEndpoints: vi.fn(async () => ({ ok: true })), + resetNetworkStatus: vi.fn(async () => {}), + isStatusStale: vi.fn(() => false), +})); + +vi.mock("../platform/extensionApi.js", () => ({ + alarmsClear: mocks.alarmsClear, + getExtensionApi: () => ({ + runtime: { + id: "test-runtime", + getManifest: () => ({ version: "0.0.0-test" }), + onMessage: { + addListener: (fn) => { + mocks.listener = fn; + }, + }, + onInstalled: { addListener: vi.fn() }, + onConnect: { addListener: vi.fn() }, + }, + alarms: { + create: mocks.alarmsCreate, + onAlarm: { + addListener: (fn) => { + mocks.alarmListener = fn; + }, + }, + }, + }), + runtimeGetURL: (path) => String(path ?? ""), + runtimeSendMessage: mocks.runtimeSendMessage, + storageSessionGet: mocks.storageSessionGet, + storageSessionSet: mocks.storageSessionSet, + storageSessionRemove: mocks.storageSessionRemove, + tabsCreate: vi.fn(async () => ({ id: 1 })), +})); + +function activityRecord() { + return mocks.sessionStore.get(AUTO_LOCK_ACTIVITY_KEY); +} + +async function flushAsync() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function importBackground() { + await import("./index.js"); + await flushAsync(); + expect(mocks.listener).toBeTypeOf("function"); + expect(mocks.alarmListener).toBeTypeOf("function"); +} + +async function restartBackground() { + vi.resetModules(); + mocks.listener = null; + mocks.alarmListener = null; + await importBackground(); +} + +async function sendBackgroundMessage(message, sender = {}) { + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("sendResponse timed out")), 1000); + mocks.listener(message, sender, (response) => { + clearTimeout(timeout); + resolve(response); + }); + }); +} + +async function fireAutoLockAlarm() { + mocks.alarmListener({ name: AUTO_LOCK_ALARM_NAME }); + await flushAsync(); +} + +describe("background auto-lock activity", () => { + let dateNowSpy; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.listener = null; + mocks.alarmListener = null; + mocks.sessionStore.clear(); + mocks.settings = { + autoLockTimeoutMinutes: 5, + nodeUrl: "https://testnet.nodes.dusk.network", + }; + mocks.engineUnlocked = true; + mocks.now = 1_000_000; + mocks.sentMessages = []; + dateNowSpy = vi.spyOn(Date, "now").mockImplementation(() => mocks.now); + + await importBackground(); + vi.clearAllMocks(); + }); + + afterEach(() => { + dateNowSpy?.mockRestore(); + }); + + it("keeps an unlocked wallet unlocked before timeout after service worker memory resets", async () => { + await sendBackgroundMessage({ type: "DUSK_UI_UNLOCK", password: "pw" }); + expect(activityRecord()).toEqual({ lastActivityAt: 1_000_000 }); + + mocks.engineCall.mockClear(); + mocks.broadcastProfilesChangedAll.mockClear(); + + mocks.now += 60_000; + await restartBackground(); + await fireAutoLockAlarm(); + + expect(mocks.engineCall).not.toHaveBeenCalledWith("engine_lock"); + expect(mocks.broadcastProfilesChangedAll).not.toHaveBeenCalled(); + expect(mocks.engineUnlocked).toBe(true); + }); + + it("locks and broadcasts profilesChanged only after persisted inactivity exceeds timeout", async () => { + await sendBackgroundMessage({ type: "DUSK_UI_UNLOCK", password: "pw" }); + mocks.engineCall.mockClear(); + mocks.broadcastProfilesChangedAll.mockClear(); + + mocks.now += 60_000; + await restartBackground(); + await fireAutoLockAlarm(); + expect(mocks.engineCall).not.toHaveBeenCalledWith("engine_lock"); + expect(mocks.broadcastProfilesChangedAll).not.toHaveBeenCalled(); + + mocks.now = 1_000_000 + 5 * 60_000 + 1; + await fireAutoLockAlarm(); + + expect(mocks.engineCall).toHaveBeenCalledWith("engine_lock"); + expect(mocks.broadcastProfilesChangedAll).toHaveBeenCalledTimes(1); + expect(mocks.sentMessages).toContainEqual( + expect.objectContaining({ + type: "DUSK_UI_LOCK_STATE", + isUnlocked: false, + reason: "auto_lock", + }) + ); + expect(activityRecord()).toBeUndefined(); + }); + + it("manual lock clears persisted activity and broadcasts profilesChanged", async () => { + mocks.sessionStore.set(AUTO_LOCK_ACTIVITY_KEY, { lastActivityAt: 1_000_000 }); + + await expect(sendBackgroundMessage({ type: "DUSK_UI_LOCK" })).resolves.toEqual({ ok: true }); + + expect(mocks.engineCall).toHaveBeenCalledWith("engine_lock"); + expect(mocks.broadcastProfilesChangedAll).toHaveBeenCalledTimes(1); + expect(mocks.sentMessages).toContainEqual( + expect.objectContaining({ + type: "DUSK_UI_LOCK_STATE", + isUnlocked: false, + reason: "manual_lock", + }) + ); + expect(activityRecord()).toBeUndefined(); + }); + + it("initializes missing activity for an unlocked wallet instead of locking immediately", async () => { + expect(activityRecord()).toBeUndefined(); + + await fireAutoLockAlarm(); + + expect(mocks.engineCall).not.toHaveBeenCalledWith("engine_lock"); + expect(mocks.broadcastProfilesChangedAll).not.toHaveBeenCalled(); + expect(activityRecord()).toEqual({ lastActivityAt: 1_000_000 }); + }); + + it("persists DUSK_UI_ACTIVITY heartbeats", async () => { + mocks.now = 1_234_567; + + await expect(sendBackgroundMessage({ type: "DUSK_UI_ACTIVITY" })).resolves.toEqual({ ok: true }); + + expect(activityRecord()).toEqual({ lastActivityAt: 1_234_567 }); + }); + + it("changing auto-lock setting restarts the alarm and keeps sane unlocked activity", async () => { + mocks.now = 2_000_000; + + await expect( + sendBackgroundMessage({ type: "DUSK_UI_SET_AUTO_LOCK", autoLockTimeoutMinutes: 15 }) + ).resolves.toEqual({ ok: true, autoLockTimeoutMinutes: 15 }); + + expect(mocks.settings.autoLockTimeoutMinutes).toBe(15); + expect(mocks.alarmsClear).toHaveBeenCalledWith(AUTO_LOCK_ALARM_NAME); + expect(mocks.alarmsCreate).toHaveBeenCalledWith(AUTO_LOCK_ALARM_NAME, { + periodInMinutes: 1, + }); + expect(activityRecord()).toEqual({ lastActivityAt: 2_000_000 }); + }); + + it("successful unlocked dApp actions refresh persisted activity", async () => { + mocks.now = 3_000_000; + + await expect( + sendBackgroundMessage( + { + type: "DUSK_RPC_REQUEST", + id: "rpc-1", + request: { method: "dusk_sendTransaction", params: { kind: "transfer" } }, + }, + { url: "https://dapp.example/page", tab: { url: "https://dapp.example/page" } } + ) + ).resolves.toEqual({ + id: "rpc-1", + result: { method: "dusk_sendTransaction" }, + }); + + expect(activityRecord()).toEqual({ lastActivityAt: 3_000_000 }); + }); + + it("real dApp network switches refresh persisted activity", async () => { + mocks.sessionStore.set(AUTO_LOCK_ACTIVITY_KEY, { lastActivityAt: 1_000_000 }); + mocks.now = 3_000_000; + + await expect( + sendBackgroundMessage( + { + type: "DUSK_RPC_REQUEST", + id: "rpc-switch", + request: { method: "dusk_switchNetwork", params: { nodeUrl: "https://nodes.dusk.network" } }, + }, + { url: "https://dapp.example/page", tab: { url: "https://dapp.example/page" } } + ) + ).resolves.toEqual({ + id: "rpc-switch", + result: { method: "dusk_switchNetwork" }, + }); + + expect(activityRecord()).toEqual({ lastActivityAt: 3_000_000 }); + }); + + it("passive connected dApp polling does not refresh persisted activity", async () => { + mocks.sessionStore.set(AUTO_LOCK_ACTIVITY_KEY, { lastActivityAt: 1_000_000 }); + mocks.now = 3_000_000; + + for (const [id, request] of [ + ["rpc-profiles", { method: "dusk_profiles" }], + ["rpc-balance", { method: "dusk_getPublicBalance" }], + ["rpc-chain", { method: "dusk_chainId" }], + [ + "rpc-switch", + { + method: "dusk_switchNetwork", + params: { nodeUrl: "https://testnet.nodes.dusk.network" }, + }, + ], + ]) { + await expect( + sendBackgroundMessage( + { + type: "DUSK_RPC_REQUEST", + id, + request, + }, + { url: "https://dapp.example/page", tab: { url: "https://dapp.example/page" } } + ) + ).resolves.toEqual({ + id, + result: { method: request.method }, + }); + } + + expect(activityRecord()).toEqual({ lastActivityAt: 1_000_000 }); + }); +}); diff --git a/src/background/dappEvents.test.js b/src/background/dappEvents.test.js index e6a9f01..06d9566 100644 --- a/src/background/dappEvents.test.js +++ b/src/background/dappEvents.test.js @@ -212,6 +212,15 @@ describe("dappEvents", () => { (m) => m?.type === "DUSK_PROVIDER_EVENT" && m?.name === "profilesChanged" ); expect(profileMsgs.at(-1)?.data).toEqual([]); + + const lockedPort = new FakePort("https://dapp.example", 2); + ev.registerDappPort(lockedPort); + await new Promise((r) => setTimeout(r, 0)); + const lockedStateMsg = lockedPort.messages.find((m) => m?.type === "DUSK_PROVIDER_STATE"); + expect(lockedStateMsg?.state).toMatchObject({ + isConnected: true, + profiles: [], + }); }); it("does not bind non-local HTTP sender origins even when permission exists", async () => { diff --git a/src/background/index.js b/src/background/index.js index 1a2f1a3..7dbd7ed 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -8,6 +8,7 @@ import { revokeOrigin, } from "../shared/permissions.js"; import { getSettings, setSettings } from "../shared/settings.js"; +import { storage, STORAGE_KEYS } from "../shared/storage.js"; import { ERROR_CODES, rpcError } from "../shared/errors.js"; import { TX_KIND } from "../shared/constants.js"; import { applyTxDefaults } from "../shared/txDefaults.js"; @@ -59,6 +60,9 @@ import { getExtensionApi, runtimeGetURL, runtimeSendMessage, + storageSessionGet, + storageSessionRemove, + storageSessionSet, tabsCreate, } from "../platform/extensionApi.js"; @@ -70,13 +74,120 @@ const ext = getExtensionApi(); // Auto-lock timer // ------------------------------ const AUTO_LOCK_ALARM_NAME = "dusk_auto_lock_check"; - -/** Last activity timestamp in memory (reset on unlock, updated on activity). */ +const AUTO_LOCK_ACTIVITY_KEY = STORAGE_KEYS.AUTO_LOCK_ACTIVITY; +const DAPP_ACTIVITY_METHODS = new Set([ + "dusk_sendTransaction", + "dusk_watchAsset", + "dusk_signMessage", + "dusk_signAuth", +]); + +/** Last activity timestamp cache; persisted storage survives worker restarts. */ let lastActivityTimestamp = 0; +function normalizeActivityTimestamp(value) { + const raw = value && typeof value === "object" ? value.lastActivityAt : value; + const n = Number(raw ?? 0); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +async function readStoredActivityTimestamp() { + try { + const items = await storageSessionGet(AUTO_LOCK_ACTIVITY_KEY); + return normalizeActivityTimestamp(items?.[AUTO_LOCK_ACTIVITY_KEY]); + } catch { + // fall back below + } + try { + const items = await storage.get(AUTO_LOCK_ACTIVITY_KEY); + return normalizeActivityTimestamp(items?.[AUTO_LOCK_ACTIVITY_KEY]); + } catch { + return 0; + } +} + +async function writeStoredActivityTimestamp(timestamp) { + const record = { lastActivityAt: timestamp }; + try { + await storageSessionSet({ [AUTO_LOCK_ACTIVITY_KEY]: record }); + return; + } catch { + // fall back below + } + try { + await storage.set({ [AUTO_LOCK_ACTIVITY_KEY]: record }); + } catch { + // Best effort; the in-memory timestamp still protects this worker instance. + } +} + +async function removeStoredActivityTimestamp() { + try { + await storageSessionRemove(AUTO_LOCK_ACTIVITY_KEY); + return; + } catch { + // fall back below + } + try { + await storage.remove(AUTO_LOCK_ACTIVITY_KEY); + } catch { + // ignore + } +} + +async function readActivityTimestamp() { + if (lastActivityTimestamp > 0) return lastActivityTimestamp; + lastActivityTimestamp = await readStoredActivityTimestamp(); + return lastActivityTimestamp; +} + /** Update activity timestamp to prevent auto-lock. */ -function updateActivity() { - lastActivityTimestamp = Date.now(); +async function updateActivity(timestamp = Date.now()) { + lastActivityTimestamp = timestamp; + await writeStoredActivityTimestamp(timestamp); +} + +async function clearActivity() { + lastActivityTimestamp = 0; + await removeStoredActivityTimestamp(); +} + +async function ensureActivityTimestamp() { + const current = await readActivityTimestamp(); + if (current > 0) return current; + const now = Date.now(); + await updateActivity(now); + return now; +} + +async function ensureActivityTimestampIfUnlocked() { + const status = await getEngineStatus(); + if (!status?.isUnlocked) return 0; + return await ensureActivityTimestamp(); +} + +async function prepareDappActivityContext(request) { + const method = String(request?.method ?? ""); + if (method !== "dusk_switchNetwork") return null; + try { + const settings = await getSettings(); + return { nodeUrl: String(settings?.nodeUrl ?? "") }; + } catch { + return { nodeUrl: "" }; + } +} + +async function updateDappActivity(request, context = null) { + const method = String(request?.method ?? ""); + if (!DAPP_ACTIVITY_METHODS.has(method)) { + if (method !== "dusk_switchNetwork") return; + const beforeNodeUrl = String(context?.nodeUrl ?? ""); + const afterNodeUrl = String((await getSettings())?.nodeUrl ?? ""); + if (!beforeNodeUrl || !afterNodeUrl || beforeNodeUrl === afterNodeUrl) return; + } + const status = await getEngineStatus(); + if (!status?.isUnlocked) return; + await updateActivity(); } function nullifierHexes(value) { @@ -229,13 +340,20 @@ async function handleAutoLockAlarm() { const status = await getEngineStatus(); if (!status?.isUnlocked) return; // Already locked. - const elapsed = Date.now() - lastActivityTimestamp; + const lastActivityAt = await readActivityTimestamp(); + if (!lastActivityAt) { + await updateActivity(); + return; + } + + const elapsed = Date.now() - lastActivityAt; const timeoutMs = timeout * 60 * 1000; if (elapsed >= timeoutMs) { console.log("[Dusk] Auto-locking wallet due to inactivity."); try { await engineCall("engine_lock"); + await clearActivity(); emitUiLockState(false, "auto_lock").catch(() => {}); broadcastProfilesChangedAll().catch(() => {}); } catch (e) { @@ -353,7 +471,7 @@ ext?.runtime?.onMessage?.addListener((message, sender, sendResponse) => { try { // UI heartbeat to reset auto-lock timer. if (message?.type === "DUSK_UI_ACTIVITY") { - updateActivity(); + await updateActivity(); sendResponse({ ok: true }); return; } @@ -370,7 +488,9 @@ ext?.runtime?.onMessage?.addListener((message, sender, sendResponse) => { } const id = message.id; + const activityContext = await prepareDappActivityContext(message.request); const result = await handleRpc(origin, message.request); + await updateDappActivity(message.request, activityContext); sendResponse({ id, result }); return; } @@ -542,7 +662,7 @@ ext?.runtime?.onMessage?.addListener((message, sender, sendResponse) => { : (await getEngineStatus()).accounts; // Reset activity timer and ensure auto-lock alarm is running. - updateActivity(); + await updateActivity(); setupAutoLockAlarm().catch(console.error); // Notify dApps that profiles are now available. @@ -556,6 +676,7 @@ ext?.runtime?.onMessage?.addListener((message, sender, sendResponse) => { // UI wants to lock if (message?.type === "DUSK_UI_LOCK") { await engineCall("engine_lock"); + await clearActivity(); // Notify dApps that profiles are no longer available. broadcastProfilesChangedAll().catch(() => {}); @@ -789,6 +910,11 @@ ext?.runtime?.onMessage?.addListener((message, sender, sendResponse) => { if (message?.type === "DUSK_UI_SET_AUTO_LOCK") { const timeout = Number(message.autoLockTimeoutMinutes ?? 0); await setSettings({ autoLockTimeoutMinutes: timeout }); + if (timeout > 0) { + await ensureActivityTimestampIfUnlocked(); + } else { + await clearActivity(); + } await setupAutoLockAlarm(); sendResponse({ ok: true, autoLockTimeoutMinutes: timeout }); return; diff --git a/src/background/rpc.test.js b/src/background/rpc.test.js index 9614ad9..a9785df 100644 --- a/src/background/rpc.test.js +++ b/src/background/rpc.test.js @@ -217,6 +217,48 @@ describe("background rpc handler", () => { expect(approveOrigin).not.toHaveBeenCalled(); }); + it("connected-but-locked dApps can request profiles to trigger unlock and reconnect UX", async () => { + vi.resetModules(); + const { handleRpc } = await import("./rpc.js"); + + vaultValue = { v: 1 }; + perms["https://dapp.example"] = { + profileId: "account:0:acct0", + accountIndex: 0, + grants: { publicAccount: true, shieldedReceiveAddress: false }, + connectedAt: 1, + updatedAt: 1, + }; + engineStatus = { + isUnlocked: false, + accounts: ["acct0"], + addresses: ["addr0"], + selectedAccountIndex: 0, + }; + requestUserApproval.mockImplementationOnce(async () => { + engineStatus = { + isUnlocked: true, + accounts: ["acct0"], + addresses: ["addr0"], + selectedAccountIndex: 0, + }; + return { accountIndex: 0 }; + }); + + const profiles = await handleRpc("https://dapp.example", { method: "dusk_requestProfiles" }); + + expect(requestUserApproval).toHaveBeenCalledWith( + "connect", + "https://dapp.example", + expect.objectContaining({ + requestedProfiles: true, + currentProfileId: "account:0:acct0", + currentAccountIndex: 0, + }) + ); + expect(profiles).toEqual([{ profileId: "account:0:acct0", account: "acct0" }]); + }); + it("dusk_requestProfiles stores a profile-scoped public-account grant", async () => { vi.resetModules(); const { handleRpc } = await import("./rpc.js"); diff --git a/src/background/txLifecycle.flow.test.js b/src/background/txLifecycle.flow.test.js index f5e2274..c43c621 100644 --- a/src/background/txLifecycle.flow.test.js +++ b/src/background/txLifecycle.flow.test.js @@ -124,6 +124,9 @@ vi.mock("../platform/extensionApi.js", () => ({ mocks.sentMessages.push(message); return { ok: true }; }), + storageSessionGet: vi.fn(async () => ({})), + storageSessionSet: vi.fn(async () => {}), + storageSessionRemove: vi.fn(async () => {}), tabsCreate: vi.fn(async () => ({ id: 1 })), })); diff --git a/src/integration/providerDiscovery.test.js b/src/integration/providerDiscovery.test.js index 10b4ae0..2bc9499 100644 --- a/src/integration/providerDiscovery.test.js +++ b/src/integration/providerDiscovery.test.js @@ -23,9 +23,13 @@ class TestMessageEvent extends Event { async function runInpageScript() { const source = await readFile(inpageUrl, "utf8"); const window = new EventTarget(); + const posted = []; window.window = window; window.location = { origin: "https://dapp.example" }; - window.postMessage = () => {}; + window.posted = posted; + window.postMessage = (msg, targetOrigin) => { + posted.push({ msg, targetOrigin }); + }; window.MessageEvent = TestMessageEvent; const context = vm.createContext({ @@ -43,12 +47,33 @@ async function runInpageScript() { Date, Math, Error, + crypto: { randomUUID: () => `req-${posted.length + 1}` }, }); vm.runInContext(source, context); return window; } +function dispatchWalletMessage(window, data) { + window.dispatchEvent( + new window.MessageEvent("message", { + source: window, + data: { + target: "DUSK_WALLET_EXTENSION", + walletId: DUSK_WALLET_ID, + ...data, + }, + }) + ); +} + +function lastRpcRequest(window) { + return window.posted + .map((entry) => entry.msg) + .filter((msg) => msg?.type === "DUSK_RPC_REQUEST") + .at(-1); +} + describe("integration: inpage provider discovery", () => { it("announces the wallet provider through the discovery events", async () => { const window = await runInpageScript(); @@ -122,6 +147,68 @@ describe("integration: inpage provider discovery", () => { expect(events[1]).toEqual([{ profileId: "account:0:acct0", account: "acct0", shieldedAddress: "addr0" }]); }); + it("distinguishes disconnected, connected locked, and connected unlocked profile state", async () => { + const window = await runInpageScript(); + const provider = window.duskWallet; + + expect(provider.isAuthorized).toBe(false); + expect(provider.profiles).toEqual([]); + + const disconnectedProfiles = provider.request({ method: "dusk_profiles" }); + dispatchWalletMessage(window, { + type: "DUSK_RPC_RESPONSE", + id: lastRpcRequest(window).id, + response: { result: [] }, + }); + await expect(disconnectedProfiles).resolves.toEqual([]); + expect(provider.isAuthorized).toBe(false); + expect(provider.profiles).toEqual([]); + + dispatchWalletMessage(window, { + type: "DUSK_PROVIDER_STATE", + state: { + isConnected: true, + profiles: [], + chainId: "dusk:2", + }, + }); + + const lockedProfiles = provider.request({ method: "dusk_profiles" }); + dispatchWalletMessage(window, { + type: "DUSK_RPC_RESPONSE", + id: lastRpcRequest(window).id, + response: { result: [] }, + }); + await expect(lockedProfiles).resolves.toEqual([]); + expect(provider.isAuthorized).toBe(true); + expect(provider.profiles).toEqual([]); + + const profile = { profileId: "account:0:acct0", account: "acct0" }; + dispatchWalletMessage(window, { + type: "DUSK_PROVIDER_STATE", + state: { + isConnected: true, + profiles: [profile], + chainId: "dusk:2", + }, + }); + + expect(provider.isAuthorized).toBe(true); + expect(provider.profiles).toEqual([profile]); + + const events = []; + provider.on("profilesChanged", (profiles) => events.push(profiles)); + dispatchWalletMessage(window, { + type: "DUSK_PROVIDER_EVENT", + name: "profilesChanged", + data: [], + }); + + expect(events.at(-1)).toEqual([]); + expect(provider.isAuthorized).toBe(true); + expect(provider.profiles).toEqual([]); + }); + it("scopes bridge messages to Dusk Wallet", async () => { const source = await readFile(inpageUrl, "utf8"); const window = new EventTarget(); diff --git a/src/platform/extensionApi.js b/src/platform/extensionApi.js index 7722581..a06bdf0 100644 --- a/src/platform/extensionApi.js +++ b/src/platform/extensionApi.js @@ -139,6 +139,18 @@ export function storageLocalClear() { return callApi(raw?.storage?.local?.clear, [], raw?.storage?.local); } +export function storageSessionGet(keys) { + return callApi(raw?.storage?.session?.get, [keys ?? null], raw?.storage?.session); +} + +export function storageSessionSet(items) { + return callApi(raw?.storage?.session?.set, [items], raw?.storage?.session); +} + +export function storageSessionRemove(keys) { + return callApi(raw?.storage?.session?.remove, [keys], raw?.storage?.session); +} + export function offscreenCreateDocument(options) { return callApi(raw?.offscreen?.createDocument, [options], raw?.offscreen); } diff --git a/src/shared/storage.js b/src/shared/storage.js index da2f4f2..664167f 100644 --- a/src/shared/storage.js +++ b/src/shared/storage.js @@ -18,4 +18,5 @@ export const STORAGE_KEYS = { ADDRESS_BOOK: "dusk_addressbook_v1", // { [id]: { id, name, address, type, createdAt, updatedAt } } ASSETS: "dusk_assets_v1", // { [walletId]: { [networkKey]: { [profileIndex]: { tokens: [], nfts: [] } } } } NETWORK_STATUS: "dusk_network_status_v1", // { nodeStatus, proverStatus, archiverStatus, lastChecked, errors } + AUTO_LOCK_ACTIVITY: "dusk_auto_lock_activity_v1", // { lastActivityAt } };